DeepSeek Harness 的「反问用户」:ask_user_question 这条路是怎么铺的

2026-08-17

先说清楚一个前提:本文写的所有字段名、错误码和默认值,都是从 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.tsask() 里,JSDoc 自述的理由是:被别人拥有的子级没有可回答的人类,问下去会永远阻塞。错误消息本身还带了给模型的指路——把这个没解决的问题或决定,写进子 agent 的最终结果里。

这个例子能说明「反问用户」这件事在这个仓库里被切成了几段。它不是一个函数,是三层:模型面的工具、抽象的能力 seam、宿主侧的 provider。想排查这类问题,得知道自己卡在哪一层。

第一层:模型实际能填的字段只有这些

模型面的那半在 packages/interaction/tool-ask-user/src/index.ts,注册的工具名叫 ask_user_question。它对模型开放的参数只有一个 questions 数组,每一项里能填的是:id(必填,会原样回到答案里)、question(必填)、headeroptions(每项 label 必填、description 可选)、multi_select

注意两处细节。一是参数里的 multi_select 是下划线写法,而 seam 侧的类型字段叫 multiSelectexecute() 里那一行做的就是这个改名映射。二是选项的描述文案里写着一条给模型的约定:如果你推荐某一项,把它放在第一个,并在那个 label 后面追加 (Recommended)

这个后缀不是白写的。客户端 packages/client/ui-user-questions/src/client/QuestionComposer.tsx 里有个 parseRecommendedLabel(),正则只吃结尾处的 (recommended)(推荐),半角全角括号都认,大小写不敏感。但解析结果只用于渲染,选中时回调里传出去的仍是原始 label 整串(源码里 choose(option.label) 拿的就是未处理的原串)——后面你会看到,宿主校验答案时比对的也正是这个原始串。所以模型如果把 (Recommended) 写进 label,它就是 label 的一部分,别指望哪一层帮你抹掉。

还有两个字段,模型填不了detailintent。seam 的类型里有,工具参数表里没有,execute() 也没有转发它们。这是仓库里白纸黑字的两处,我只陈述这个差异,不猜为什么。

第二层:ask() 的五道闸门,顺序有讲究

请求进到 ctx.userQuestions.ask() 之后,是五段顺序检查,全部在同一个方法里:

  1. request.signal?.aborted 已经中止 → ASK_ABORTED
  2. questions.length === 0EMPTY_QUESTIONS
  3. 传了 agent 时,拿 ctx.get('agents') 核对:注册表里同 id 的实例必须是同一个对象,否则 CALLER_NOT_LIVE;再看它是否在 agents.roots() 里,不在就是开头那个 DELEGATED_CALLER
  4. 逐题校验 intentapprove 必须命中这道题自己的 options 里的某个 label,且这道题必须有 detail,两者任一不满足都是 BAD_INTENT
  5. 最后才看 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.tsexit_plan_mode:问题 id 是常量 plan-reviewheaderPlan reviewdetail 直接放整份计划 markdown,两个选项的 label 分别是 ApproveKeep planning,intent 里的 approve 指名 Approve。这里能对上前面那条限制——因为模型侧参数表没有 detailintent,能发出带 intent 请求的是进程内的插件,而不是模型自己。

客户端要不要按「计划评审」来渲染,判断写在 packages/client/ui-user-questions/src/client/contract/slots.tsplanReviewOf(),认领条件是五条同时成立:整批只有一道题、声明了 plan-reviewdetail 不是 undefined、不是 multiSelect、options 不超过 2 项且其中有一项 label 等于 intent 的 approve。任何一条不满足就整个退回通用流程。函数注释自述的原则是:这张卡只在它能发出该请求允许的每一种回答时才认领——意图改的是布局,绝不改哪些回答是可达的。

答案回来时要过的那道逐条校验

Web 宿主那一侧在 packages/host/apiproxy/src/api-proxy.ts。它用 ctx.userQuestions.registerProvider() 注册 provider,把每次 ask() 变成一条带稳定 rpcIdquestion/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_ABORTEDpackages/interaction/user-questions/src/index.ts / api-proxy.ts进来时 signal 已中止;或等待中 signal abort、provider 被 dispose
EMPTY_QUESTIONSuser-questions/src/index.tsquestions 为空数组
CALLER_NOT_LIVEuser-questions/src/index.ts传入的 agent 不是注册表里那个存活实例
DELEGATED_CALLERuser-questions/src/index.ts存活 agent 被另一个 agent 拥有
BAD_INTENTuser-questions/src/index.tsapprove 没命中本题选项,或带 intent 却没有 detail
NO_PROVIDERuser-questions/src/index.ts没有注册任何 provider
DUPLICATE_PROVIDERuser-questions/src/index.ts同一 context 里第二次注册 provider
ASK_CANCELLEDpackages/host/apiproxy/src/api-proxy.ts客户端回了 error.code === 'cancelled'
ASK_MISSING_AGENTpackages/host/apiproxy/src/api-proxy.tsWeb provider 收到不带 agent 的请求

这张表的读法是:前七个是 seam 自己的词汇,换任何 provider 都会出现;后两个只属于 Web 宿主这一个实现,你要是自己写 provider,这两个码不会自动帮你产生。

ASK_CANCELLED 被下游专门认了一次。plan-modeask() 后面挂了 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 失效,请求前缀的任何变更由上层消费方负责。这是文档自述,我们没有运行过这个项目,没法验证它的实际表现。

回到最开始那个问题

如果你要排查一次「模型问了但没问出来」,按层往下看就行:模型那一层看它填的字段是不是只有参数表里那几个(detailintent 它填不了);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 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。