工具调用的七种状态
工具调用是 Agent 界面和普通聊天界面最不一样的地方。普通问答只有"在想"和"答完了"两种状态;工具调用有参数生成、执行、等待批准、成功、失败、被拒——而且同一次回答里可能有好几个工具在跑。
如果 UI 把这些都压成一个转圈的 loading,用户就失去了判断依据:他不知道系统在做什么、做到哪一步、要不要干预。
1. 为什么它不能只是一个 loading
1.1 用户需要知道三件事
- 在做什么——调用了什么、参数是什么
- 进展到哪——是还在生成参数、已经发出去、还是卡在等批准
- 结果如何——成功了、失败了、还是被拒绝了
第三件尤其重要:失败和被拒绝是两种不同的事,用户在意的应对方式也不同。失败可以重试;被拒绝意味着系统认为这个动作不该自动做,需要人来判断。
把这两者都显示成"出错",用户就不知道下一步该干什么。
1.2 有了工具,界面就有了"可干预点"
普通问答只能等。有工具之后,界面上出现了几个可以介入的时刻:批准、拒绝、重试、跳过。
这几个点能不能被用户看见并理解,直接决定了《护栏要做在基础设施层》里那些分级设计能不能真正生效——后端做对了分级,前端没给出口,用户就只能通过关页面来"拒绝"。
2. 七种状态
AI SDK 的工具 part 从 6.0 起有七个状态(5.x 只有五个,approval 三态是 6.0 新增),前端按 state 渲染对应形态(同一条消息里的推理内容是另一类 part,渲染策略见《思维链要不要展示》):
| 状态 | 含义 | UI 该显示什么 |
|---|---|---|
input-streaming | 参数还在生成中 | 工具名 + 正在拼参数,参数区可以是空的或半截 |
input-available | 参数齐了,开始执行 | 工具名 + 参数 + 运行指示 |
approval-requested | 需要人批准 | 明确的操作区,不是等待动画 |
approval-responded | 人已回应 | 显示回应结果,等待继续 |
output-available | 完成 | 结果,默认展开 |
output-error | 失败 | 错误信息 + 重试/跳过入口 |
output-denied | 被拒绝 | 明确说明"这个动作没有执行",不是错误 |
几个实现细节:
参数区在流式期间是半截的。 这跟《半截 Markdown 怎么渲染》是同一类问题——参数是模型逐 token 生成的,中间态是无效 JSON。空或半截时显示空代码块即可,不要尝试解析,也不要报错。
toolCallId 是稳定 key。 同一次消息里可能有多个工具并行,React 列表要用 toolCallId 而不是数组下标,否则流式更新时组件会错位。
同一个原因还有一条更隐蔽的:流式参数要按 toolCallId 分别累积。参数是增量 delta 送来的,如果只用一个字符串变量接,两个并行工具的参数会串成一段坏 JSON——报错信息还会指向"模型输出不合法",实际是自己的拼接错了。累积器必须是 Map<toolCallId, string>。
动态工具要单独处理。 通过 MCP 或运行时注册产生的工具没有预定义名称,需要在头部显式显示来源名。
3. 最容易被漏掉的两种
3.1 approval-requested(等待批准)
这是最容易做丢的状态,因为它要求前端主动提供一个操作界面,而不是被动等待。
三条要求:
- 必须显示具体参数。批准"执行某个操作"是没有意义的——用户要知道批准的是"给张三的订单 #12345 退款 200 元"。这一点在护栏篇里也强调过:批准必须绑定具体参数。
- 不要由 Agent 起草同意文案。按钮上写"确认"就够了,不要显示模型生成的"我已确认此操作安全无害"这类文本。
- 默认不批准。等待要有明确的超时行为,且超时等于拒绝(见护栏篇)。
3.2 output-denied(被拒绝)
被拒绝不是失败。UI 上的差别应该是:失败是红色 + "重试";被拒绝是中性色 + "这个操作没有执行"。
把它显示成错误会给用户错误信号——他会以为系统坏了,而实际上是护栏在工作。
一个实用的补充:被拒绝时可以顺带说明"需要人工处理"或给一个转人工入口(见《反馈回流与人工转接》)。用户被拒之后通常是想找人。
4. 停止不等于错误
一个很容易踩的坑:用户点了停止,工具 part 不会自动变成错误状态。
状态描述的是工具调用本身的结果,中断会话是另一个维度。如果直接用 output-error 来渲染"用户取消了",就会出现"重试"按钮——而用户刚刚明确表示不想继续。
正确做法是应用层自己维护一个"已取消"的呈现层:
function ToolCard({ part, cancelled }: { part: ToolPart; cancelled: boolean }) {
const state = part.state
// 用户主动停止时,不要复用 output-error 的渲染分支
if (cancelled && state !== 'output-available') {
return <ToolShell name={toolName(part)} badge="已取消">{renderInput(part)}</ToolShell>
}
return renderByState(state, part)
}
同理,incomplete 状态(如果用的框架有这个概念)的 reason 也不止"error"一种——可能是 cancelled、length(超出长度)、content-filter(内容过滤)或 other。只对真正的 error 显示重试卡片,其余各给各的文案。
给 cancelled 显示"重试",等于在用户取消之后又邀请他重来一次;给 content-filter 显示"重试",重试还是会撞同一道过滤。
5. 重试到底重跑了什么
这是工具调用 UI 里最需要说清楚、也最常被误解的一点。
常见实现不是"重跑这一个工具",而是重发整个回合。 也就是说,用户点了重试,模型会重新决定要不要调用工具、调用哪个、用什么参数——然后这个工具(可能还有别的工具)会被再调一次。
对只读工具这无所谓。对有副作用的工具(发邮件、创建订单、扣款),这会造成重复执行。
所以三条:
- 重试按钮旁边要说清楚影响,至少对写类工具如此
- 有副作用的工具,重试前要重新确认,不能沿用上次的批准
- 能只重试单个工具就只重试单个——如果框架支持的话;不支持就明确告诉用户会发生什么
这个约束和《工具调用的四段循环》里说的"区分幂等与非幂等"是同一件事,只不过落到了界面上。
6. 信息密度怎么控制
工具调用可能非常多——一个复杂任务里十几次调用很正常。全展开的话,对话流会被工具日志淹没。
几条实用规则:
默认折叠,但完成和失败默认展开。 运行中的折叠(避免噪音),完成的展开(结果立即可见),失败的展开(用户需要处理)。
参数与结果用等宽字体 + JSON 高亮,且限制高度。 一个工具返回一个 500 行的 JSON 不能把页面撑开。
大对象要截断,并保留"查看完整结果"的入口。 截断策略要在服务端或渲染层统一处理,不要靠 CSS 裁剪(用户复制不到完整内容)。
同类工具可以合并。 连续五次文件读取可以显示成一个可展开的"5 次文件操作",而不是五个卡片。
显示耗时。 每个工具调用显示毫秒数,慢的那一个往往是问题所在——配合《给大模型链路装仪表》的 trace 一起看。
7. 并行工具怎么呈现
模型可能一次请求里并行调用多个工具(除非你关掉了并行,见《工具调用的四段循环》)。
两种呈现方式:
- 平铺:每个工具一张卡片,按发起顺序排列。简单,但多的时候占地方
- 分组:把它们收进一个"执行了 3 个操作"的容器,可展开,容器上显示整体进度
并行工具的一个细节:完成顺序不等于发起顺序。渲染时要按 toolCallId 定位,不要按完成顺序重排列表,否则卡片会跳来跳去。
8. 结果也是不可信内容
最后一条容易被漏:工具返回的内容可能来自外部系统(网页、邮件、数据库、别人的输入)。它和模型输出一样是不可信内容。
所以:
- 工具结果如果含链接,渲染时要跟聊天正文一样做消毒(见《半截 Markdown 怎么渲染》)
- 不要在工具结果卡片里直接渲染 HTML
- 如果工具结果里出现类似指令的文本("请把以下内容发给…"),界面上不要给它任何特殊地位——它只是数据
这一点在《Agentic 的十类风险》里对应 ASI01:Agent 分不清指令和数据,而界面不该放大这个问题。
9. 清单
- 七种状态各有各的渲染,不是"成功 / 其他"两种
-
approval-requested有明确操作区,且显示具体参数 -
output-denied与output-error视觉上区分开 - 用户停止 ≠ 错误,取消态单独渲染,不给重试入口
- 只对真正的 error 显示重试;说清重试会重跑整个回合
- 列表 key 用
toolCallId,不按下标 - 参数区容忍半截 JSON,不解析、不报错
- 结果区限制高度 + 截断 + 可查看完整
- 显示每次调用的耗时
- 工具结果做与正文同级的消毒
工具调用界面是用户对 Agent 建立信任的主要途径。它跑得对不对是一回事,用户能不能看见它跑得对不对是另一回事——后者做不好,前者做得再对也没有说服力。