DeepSeek Harness 的「反问用户」:ask_user_question 这条路是怎么铺的
先说清楚一个前提:本文写的所有字段名、错误码和默认值,都是从 DeepSeek Harness 仓库快照里读出来的。该项目 README 第 9 行起自述处于 developer preview(开发者预览) 阶段,并用大写强调「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」——下面提到的参数名与行为随时可能变,看到本文和仓库对不上时,以仓库为准。
从一个会卡死的场景说起
假设你在一个子 agent 里让模型调 ask_user_question 反问用户。它会怎样?请求根本走不到 UI provider 那一步,ask() 直接抛错,错误码 DELEGATED_CALLER。这条判断写在 packages/interaction/user-questions/src/index.ts 的 ask() 里,JSDoc 自述的理由是:被别人拥有的子级没有可回答的人类,问下去会永远阻塞。错误消息本身还带了给模型的指路——把这个没解决的问题或决定,写进子 agent 的最终结果里。
这个例子能说明「反问用户」这件事在这个仓库里被切成了几段。它不是一个函数,是三层:模型面的工具、抽象的能力 seam、宿主侧的 provider。想排查这类问题,得知道自己卡在哪一层。
第一层:模型实际能填的字段只有这些
模型面的那半在 packages/interaction/tool-ask-user/src/index.ts,注册的工具名叫 ask_user_question。它对模型开放的参数只有一个 questions 数组,每一项里能填的是:id(必填,会原样回到答案里)、question(必填)、header、options(每项 label 必填、description 可选)、multi_select。
注意两处细节。一是参数里的 multi_select 是下划线写法,而 seam 侧的类型字段叫 multiSelect,execute() 里那一行做的就是这个改名映射。二是选项的描述文案里写着一条给模型的约定:如果你推荐某一项,把它放在第一个,并在那个 label 后面追加 (Recommended)。
这个后缀不是白写的。客户端 packages/client/ui-user-questions/src/client/QuestionComposer.tsx 里有个 parseRecommendedLabel(),正则只吃结尾处的 (recommended) 或 (推荐),半角全角括号都认,大小写不敏感。但解析结果只用于渲染,选中时回调里传出去的仍是原始 label 整串(源码里 choose(option.label) 拿的就是未处理的原串)——后面你会看到,宿主校验答案时比对的也正是这个原始串。所以模型如果把 (Recommended) 写进 label,它就是 label 的一部分,别指望哪一层帮你抹掉。
还有两个字段,模型填不了:detail 和 intent。seam 的类型里有,工具参数表里没有,execute() 也没有转发它们。这是仓库里白纸黑字的两处,我只陈述这个差异,不猜为什么。
第二层:ask() 的五道闸门,顺序有讲究
请求进到 ctx.userQuestions.ask() 之后,是五段顺序检查,全部在同一个方法里:
request.signal?.aborted已经中止 →ASK_ABORTEDquestions.length === 0→EMPTY_QUESTIONS- 传了
agent时,拿ctx.get('agents')核对:注册表里同 id 的实例必须是同一个对象,否则CALLER_NOT_LIVE;再看它是否在agents.roots()里,不在就是开头那个DELEGATED_CALLER - 逐题校验
intent:approve必须命中这道题自己的options里的某个 label,且这道题必须有detail,两者任一不满足都是BAD_INTENT - 最后才看
this.provider是否为空 →NO_PROVIDER
顺序值得记一下:NO_PROVIDER 排在最后。也就是说,一个 intent 写错的请求,即使当前根本没有任何 UI provider 注册,你先看到的也是 BAD_INTENT 而不是 NO_PROVIDER。排查时别被这个顺序带偏——报 BAD_INTENT 不代表 UI 是好的,它只代表请求还没走到检查 UI 那一步。
第 3 条那段 JSDoc 里还有一句划边界的话,值得单独拎出来:决定能不能问人的是运行时归属,不是持久化的会话谱系。一个带着历史委托深度的会话,只要恢复成新的运行时根,就能正常提问;反过来,一个归属于另一个存活 agent 的子级会被拒。
第三层:intent 只改呈现,不改协议
AskUserQuestionIntent 目前只有一种 kind,就是 plan-review。类型定义在 packages/interaction/user-questions/src/types.ts,注释写得很直白:意图只改变呈现方式,不认识这个标签的 UI 就渲染通用选项列表,两种情况下调用方读到的答案字段完全相同。approve 用名字指向肯定选项,而不是靠选项顺序,注释自述的理由是不让任何 UI 从选项次序里推断结论。
我在源码里 grep 了一遍,唯一设置这个 intent 的调用方是 packages/plan/plan-mode/src/index.ts 的 exit_plan_mode:问题 id 是常量 plan-review,header 是 Plan review,detail 直接放整份计划 markdown,两个选项的 label 分别是 Approve 和 Keep planning,intent 里的 approve 指名 Approve。这里能对上前面那条限制——因为模型侧参数表没有 detail 和 intent,能发出带 intent 请求的是进程内的插件,而不是模型自己。
客户端要不要按「计划评审」来渲染,判断写在 packages/client/ui-user-questions/src/client/contract/slots.ts 的 planReviewOf(),认领条件是五条同时成立:整批只有一道题、声明了 plan-review、detail 不是 undefined、不是 multiSelect、options 不超过 2 项且其中有一项 label 等于 intent 的 approve。任何一条不满足就整个退回通用流程。函数注释自述的原则是:这张卡只在它能发出该请求允许的每一种回答时才认领——意图改的是布局,绝不改哪些回答是可达的。
答案回来时要过的那道逐条校验
Web 宿主那一侧在 packages/host/apiproxy/src/api-proxy.ts。它用 ctx.userQuestions.registerProvider() 注册 provider,把每次 ask() 变成一条带稳定 rpcId 的 question/requested 帧广播出去,然后挂起 Promise 等 respond。
答案回来后要过 matchesQuestions(),这是我认为最值得抄下来的一段。它逐条比:
sessionId必须对得上answers.length必须等于questions.length- 按下标逐个比 id——
answer.id要等于questions[index].id,所以答案的顺序也得和问题一致,不是按 id 随便匹配 selected去重后长度不变,即不许有重复标签custom去掉首尾空白后不能是空串- 非 multiSelect 时:
custom与非空selected不能并存,且selected长度不能超过 1 - 所有
selected里的标签,必须都在这道题自己的options里
最后一条和第一层那个原始 label 对上了:(Recommended) 后缀在渲染时被剥掉,如果它同时也从回传值里少了,这一步就会判定 bad-response。另外把这条和 seam 文档里那句「UI 可以用 selected 为空且无 custom 的条目保留被跳过的问题」放在一起看,还能读出一个边界:一道没有给 options 的题,labels 集合是空的,任何非空 selected 都过不了这一关,能表达的只有 custom 或者空着跳过。
取消和中止是两码事
同一次等待,结束方式不止一种,错误码也不同:
| 错误码 | 出处文件 | 触发点 |
|---|---|---|
ASK_ABORTED | packages/interaction/user-questions/src/index.ts / api-proxy.ts | 进来时 signal 已中止;或等待中 signal abort、provider 被 dispose |
EMPTY_QUESTIONS | user-questions/src/index.ts | questions 为空数组 |
CALLER_NOT_LIVE | user-questions/src/index.ts | 传入的 agent 不是注册表里那个存活实例 |
DELEGATED_CALLER | user-questions/src/index.ts | 存活 agent 被另一个 agent 拥有 |
BAD_INTENT | user-questions/src/index.ts | approve 没命中本题选项,或带 intent 却没有 detail |
NO_PROVIDER | user-questions/src/index.ts | 没有注册任何 provider |
DUPLICATE_PROVIDER | user-questions/src/index.ts | 同一 context 里第二次注册 provider |
ASK_CANCELLED | packages/host/apiproxy/src/api-proxy.ts | 客户端回了 error.code === 'cancelled' |
ASK_MISSING_AGENT | packages/host/apiproxy/src/api-proxy.ts | Web provider 收到不带 agent 的请求 |
这张表的读法是:前七个是 seam 自己的词汇,换任何 provider 都会出现;后两个只属于 Web 宿主这一个实现,你要是自己写 provider,这两个码不会自动帮你产生。
ASK_CANCELLED 被下游专门认了一次。plan-mode 在 ask() 后面挂了 catch,只要错误是 UserQuestionError 且 code 是 ASK_CANCELLED,就把它改写成一句给模型的话:用户想自己说点什么,停在 plan mode 等他的消息。注释自述的理由是——通用消息里提的是 ask_user_question,而模型这次调的其实是 exit_plan_mode,压根没碰过那个工具。
一处对不上的地方
seam 的 README.zh.md 里写着:不含 agent 的程序化请求继续沿用现有提供方路径。而 Web 宿主的 provider 在 api-proxy.ts 里第一件事就是取 request.agent?.id,取不到直接 reject,错误码 ASK_MISSING_AGENT,消息是「web user interaction requires an agent-owned session」。
两处口径不一致,我只把它们并排放在这里:seam 层面确实允许 agent 缺省,但在这个宿主实现下,不带 agent 的程序化提问是走不通的。以实读到的源码为准,至于哪一处该改,不在本文的判断范围内。
还有两条 README 自述的限制
一是每个 context 只能有一个 provider。不支持路由或扇出到多个 UI,第二次注册抛 DUPLICATE_PROVIDER,一个都没注册时 ask() 抛 NO_PROVIDER 而不降级(README 用的就是「不会降级」这个词,至于降级本可以是什么形态,文档没有展开,我也不替它补)。另外 docs/subsystems/user-questions.md 的 Provider 一节写明:provider 的注册是 effect-bound 的,所以 HMR 或 dispose 会把当前活跃的 UI 摘掉。
二是词汇只覆盖问题表单形态:可选项加可选的自由文本。README 把文件选择器、diff 预览确认这类更丰富的交互明确列进了「已知限制与暂缓事项」,说它们目前还没有 seam 词汇。想做这类交互,现在没有现成的路可走。
另外 README 里有一句适合记住的:等待人类回答不增加 token,也不会直接让 KV Cache 失效,请求前缀的任何变更由上层消费方负责。这是文档自述,我们没有运行过这个项目,没法验证它的实际表现。
回到最开始那个问题
如果你要排查一次「模型问了但没问出来」,按层往下看就行:模型那一层看它填的字段是不是只有参数表里那几个(detail 和 intent 它填不了);seam 那一层按上面五道闸门的顺序读错误码,尤其记住 NO_PROVIDER 排在最后;宿主那一层看答案有没有过 matchesQuestions() 的逐条校验,顺序、去重、空白 custom、非法 label 都会被判成 bad-response。三层的入口文件就是本文提到的那三个,直接翻过去比读任何转述都快。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。