把补参从模板里拿出来:Agent Clarify 改造成结构化 Tool Call

这次在改工业 AI 助手的编排层时,前面已经把 LangGraph + checkpoint 那层黑盒拆掉了,但 clarify 这件事还是不顺。问题不在“能不能让模型问用户要参数”,而在“这句补参话术到底应该由谁写”。

一开始很容易想到两条路:

  1. 继续把补参文案硬编码在工具 schema 里,比如 x-clarifyTemplate
  2. 让模型自己在普通 content 里判断这是补参还是最终回答

这两条路都能跑,但都不够稳。前者维护成本高,后者运行时语义太虚。最后收敛下来的做法是把 clarify 拆成两步:

  • 第一步,模型通过原生 function calling 明确声明“缺了什么参数”
  • 第二步,系统再让模型根据这些缺参元数据生成真正给用户看的澄清话术

这篇就只写这件事。

原来的 clarify 为什么不好维护

当前这类业务工具本身会带一些字段元数据,比如:

  • 内部字段名:fnCode
  • 中文业务名:炉号
  • 参数说明:需要用户明确指定哪台炉
  • 有时还会带一些 x-* 扩展字段

问题出在最后一类。

如果把 x-clarifyTemplate 当成长期方案,工具 schema 很快就会开始长成下面这样:

  • x-clarifyTemplate
  • x-userLabel
  • x-example
  • x-normalizationRule
  • 还有各种和 prompt 一样长的说明文字

这会带来三个直接问题。

第一,文案维护会散。
工具一多,澄清句子就会分散在各个 schema 里。后面你想统一语气、统一产品口径、统一“示例怎么写”,改起来会很烦。

第二,schema 会开始承载不该它承载的东西。
工具 schema 应该回答的是“参数长什么样、哪些必填、哪些可枚举”,不是“缺参数时这句话怎么说才更自然”。

第三,多轮上下文会越来越难处理。
同样缺 fnCode,如果用户上一轮已经提到“查今天的炉次情况”,和用户这一轮直接问“帮我查生产情况”,澄清文案不应该完全一样。模板很难把这种上下文差异处理好。

另一条路为什么也不够稳

另一种更激进的想法是:不用专门的 clarify 语义,直接让模型在普通 content 里输出一句补参话术。

这样表面上更省事,因为 loop 会很像这样:

1
2
有 tool_call => 执行工具
没有 tool_call => 直接把 content 返回给用户

这里的问题不是功能做不到,而是系统很难稳定判断:

  • 这是最终回答
  • 还是一条待恢复的澄清问题

如果两者都走裸 content,运行时就得靠额外 prompt 约束或者字符串规则猜。这样一来:

  • 事件语义会变糊
  • pending_action 很难稳定落库
  • 前端也不知道当前这轮到底是 final 还是 clarification
  • 以后做 resume、审计、统计都会变难

这和前面拆黑盒的目标是反着来的。

这次确定下来的结构

最后比较稳的做法其实很简单:让 clarify 变成一个显式的结构化信号,但不要让它负责写文案。

第一步:用一个本地 Tool 表达“缺参数”

给模型额外提供一个本地 tool,比如 request_clarification

它不去调用 Java MCP,不查外部数据,也不拼用户话术。它只负责告诉运行时三件事:

  1. 当前不能继续查证据
  2. 缺了哪些参数
  3. 这些参数的业务名字是什么

结构大概像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "request_clarification",
"arguments": {
"question_goal": "查询今天炉次生产情况",
"tool_name": "today_furnace_batches",
"missing_fields": [
{
"name": "fnCode",
"label": "炉号",
"description": "需要明确具体炉体"
}
]
}
}

这里最关键的是:

  • name 给运行时用
  • label 给用户表达用
  • description 给 LLM 生成澄清文案时参考

这个 tool call 一出来,系统就知道这是 clarify,不会和 final 混掉。

第二步:再让模型专门生成一条澄清话术

真正给用户看的那句话,不再写死在 schema 里,而是用另一个很小的 prompt 现场生成。

输入内容可以很克制:

  • 用户原问题
  • 缺参列表
  • 每个参数的中文业务名
  • 当前用户可见的候选范围
  • 几条写作约束

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户问题:今天炉次生产情况怎么样
缺失信息:
- 炉号:需要明确具体炉体
可见炉体:
- 9号炉
- 11号炉
- A号炉

请生成一句简洁、自然、面向用户的补参问题:
- 不要泄露内部字段名
- 不要提 tool/function/schema
- 可以给 2 到 3 个示例
- 只输出最终话术

模型输出:

1
请问您想查询哪台炉的生产情况?例如 9号炉、11号炉 或 A号炉。

这句就是最后发给前端的 clarification_needed.content

这套结构为什么更稳

我最后比较看重的是两个边界。

1. “缺了什么”必须结构化

这个判断不能再交给第二次 LLM 去猜。

缺参判断一旦不结构化,后面就会出现一串连锁问题:

  • 这轮到底是不是 clarify
  • 要不要写 pending_action
  • resume 时应该拿哪些字段合并用户补充输入
  • 前端该不该渲染成补参态

所以第一层必须是机器可判定的结构。

2. “怎么问用户”可以交给 LLM

这部分反而适合交给模型做,因为它天然擅长:

  • 把同一个参数换成更自然的业务表达
  • 根据当前上下文调整话术
  • 根据候选值给用户几个容易理解的示例

运行时不需要维护一堆模板,也不用把语言风格散落在工具 schema 里。

改完以后,loop 会变成什么样

编排会比现在干净很多。主循环只需要处理三种结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
1. 返回业务 tool_call
- 执行工具
- 写入 tool_result
- 继续 loop

2. 返回 request_clarification
- 生成澄清文案
- 写入 clarification_needed
- 保存 pending_action
- 本轮结束

3. 没有 tool_call
- content 直接作为 final answer

这套 loop 跟 nanobot 那种原生 function calling 骨架已经很接近了,但又保留了工业助手需要的补参恢复语义。

这次我不想再让 schema 扛文案了

这轮最值钱的地方,不是“加了一个 clarify tool”,而是把责任重新切清楚了。

工具 schema 回到它该做的事:

  • 描述参数
  • 提供字段级业务标签
  • 提供必要的约束和可选值

LLM 去做它更适合的事:

  • 把结构化缺参信息转成自然对话

运行时负责把这两件事接起来:

  • 明确记录 clarify 语义
  • 明确保存 resume 所需状态

这样后面就算要换模型、换文风、换联调环境,也不会再去一堆 x-clarifyTemplate 里翻文案。

代价也得说清楚

这套方案不是没有代价。

第一,clarify 分支从“一次 LLM 调用”变成了“两次”。
第一次做 function calling 决策,第二次生成澄清文案。虽然第二次 prompt 很小,但它确实多了一次模型调用。

第二,对 provider 能力有要求。
模型必须稳定支持原生 function calling,否则这条链路会退化回 JSON 解析。

第三,字段级元数据还是要维护。
虽然不再维护整句模板,但 label/description/examples 这些基础业务元数据还是要保持干净,不然第二次生成的澄清话术也不会好。

这些代价都能接受,因为它们换回来的是更清楚的运行时边界和更低的长期维护成本。

收口

这次 clarify 的改造,真正让我满意的点不是“代码更短”,而是 finally 把三件事分开了:

  • 模型判断需不需要继续查证据
  • 运行时判断当前是不是 clarify
  • 模型负责把缺参信息说成人话

这三件事混在一起时,代码会越来越像 prompt 工程。把它们拆开以后,编排才更像一个可以长期维护的系统。


把补参从模板里拿出来:Agent Clarify 改造成结构化 Tool Call
https://willfordzhan.github.io/2026/03/13/agent-clarify-tool-call/
作者
詹文杰
发布于
2026年3月13日
许可协议