第三话结束时,我们留下一个问题没解决:
tool_calls永远是候选,不是命令。谁来判断"能不能执行、怎么执行"?
这就是第四话要回答的。模型提出的工具调用,哪怕 JSON 合法、参数齐全,也可能是错的、越权的、有副作用的。Runtime 不能照单全收——它得先校验,再执行,而且要在执行出问题时不把局面搞得更糟。
这里最危险的,不是选错工具,而是一个有副作用、结果又说不清的操作。比如模型要调 refund_order 退款:
handler 执行到一半超时了。钱到底退了没有?直接重试,可能重复退款;不重试,用户又以为没退成。
这一话就沿着这条退款主线,讲清楚 Runtime 怎么把"模型说要调用"变成"安全地执行"——以及最关键的,超时之后怎么根据真实状态做决定,而不是靠猜。
一、模型说"调 refund_order",Runtime 先干什么?
模型返回的 tool_calls 里,是一段结构化申请单:工具名 refund_order、参数 order_id 和 idempotency_key。但它是模型生成的,是外部输入,不是已经校验过的命令。
Runtime 拿到它,第一件事不是执行,是问三个问题:
- 这个工具名,是允许执行的吗?
- 参数齐全、类型对吗?
- 这个工具现在可用吗?
任何一个不过,就不进 handler,而是返回一个结构化的错误,让上层能稳定地分支处理。
为什么要结构化,不能是一段"调用失败,请稍后再试"的文案?因为文案里没有可编程的信息——程序没法可靠地区分"工具不存在"和"参数缺了"和"执行到一半挂了"。而这三种情况,处理方式完全不同:
| 错误码 | 发生了什么 | 该怎么处理 |
|---|---|---|
UNKNOWN_TOOL | 模型报了个不存在的工具名 | 拒绝,修正工具选择 |
INVALID_ARGUMENT | 参数不满足契约 | 补参数或拒绝 |
TOOL_UNAVAILABLE | 工具已下线或不健康 | 告知不可用,不能硬调 |
EXECUTION_ERROR | handler 或下游执行失败 | 按恢复语义决定,见下一节 |
前三个错误,handler 根本没执行——它们发生在执行之前。第四个才是真正进到了执行阶段,也是最麻烦的。
二、执行时出了问题,最怕的是"结果说不清"
refund_order 通过了校验,handler 真的去调订单服务了——然后超时。
超时意味着什么?意味着"结果不确定"。请求可能压根没到达订单服务,也可能已经到达、退款都成功了,只是响应没传回来。你分不清。
这就是最危险的地方:如果 Runtime 把"超时"直接当成"没执行",再调一次,就可能退了两笔钱。而如果当成"失败了",用户这边又显示退款失败,但其实钱已经退了——两边对不上。
所以规则只有一条:超时之后,先查这笔操作的真实状态,不要直接重试。
怎么查?靠 idempotency_key(幂等键)。它是发起退款时就带上的、这笔操作的唯一标识。领域服务按这个键记录"这笔退款到底执行了没有",Runtime 超时后先去查它,而不是再发起一笔。
三条分支,对应三种真实状态:
SUCCEEDED:钱已经退了。Runtime 把成功结果回放给用户,不再调 handler。这是最容易犯错的场景——明明已经成功,却因为"没收到响应"而再退一次。NOT_EXECUTED:确认没执行过。这时重试一次是安全的。UNKNOWN/ 执行中:状态也查不到。这时既不能重试,也不能谎报成功,只能等待对账或转人工核验。
跑一遍主线 demo,你会看到这样一条 Trace:
refund_order response=EXECUTION_ERROR
idempotency_status=SUCCEEDED
runtime_action=REPLAY_SUCCESS
final=SUCCESS
refund_order handler_execution_count=1
最后一行是整条链路的关键证据:handler_execution_count=1。它证明 Runtime 虽然经历了"超时 → 查状态 → 回放",但真正执行退款的 handler 只跑了一次。没有重复退款。
三、把"谁负责什么"钉死
超时恢复这件事,看着是 Runtime 一个人的活,其实牵涉三个角色,职责不能混:
| 角色 | 负责什么 | 不负责什么 |
|---|---|---|
领域服务(RefundDomain) | 记录和查询"退款到底发生没有" | 不负责重试策略,也不知道 Runtime 怎么编排 |
| Runtime | 按恢复语义,决定回放、重试还是等待 | 不拥有退款事实,退款有没有发生得问领域 |
| 模型 | 只提出候选调用 | 不拥有执行权,更不能拿它的话当退款状态 |
一句话:有副作用工具的超时恢复,首先是领域状态问题,其次才是 Runtime 的重试策略问题。 退款有没有发生,是领域事实,只能问领域服务;Runtime 能做的是照着这个事实去编排下一步,而不是自己猜。
反过来看,如果领域服务不记录幂等状态,Runtime 再聪明也没用——它没有可以查询的真相,超时之后只能抓瞎。所以幂等这件事,得从工具设计那天就带上,而不是等出了超时才补。
四、把这条链路跑起来
配套 demo 在 code/agent-tutorial/c04-tool-registry/,Python 和 Java 各一份,语义一致。它不调用 LLM、订单或支付系统——所有 handler 都是本地确定性模拟,所以它验证的是 Runtime 的控制流,不是退款系统的可用性证明。
cd code/agent-tutorial/c04-tool-registry/python
python3 registry.py
python3 -m unittest test_registry.py
cd ../java
gradle run
它覆盖六类场景,每一类都对应上面讲的一个环节:
| 场景 | 验证什么 |
|---|---|
| 工具不存在 | UNKNOWN_TOOL,不进 handler |
| 工具下线 | TOOL_UNAVAILABLE,不能硬调 |
| 参数缺失 | INVALID_ARGUMENT,补参或拒绝 |
| 正常调用 | 一次执行,SUCCEEDED |
| 超时但已成功 | 查幂等 → SUCCEEDED → 回放,handler_execution_count=1 |
| 超时且未执行 | 查幂等 → NOT_EXECUTED → 重试一次 |
其中"超时但已成功"是最值得盯着看的一条:它模拟了 handler 执行成功后、响应却丢失的场景——退款已经发生,SUCCEEDED,但 Runtime 收到的是超时。正确的 Trace 必须是"回放成功结果",而不是"再退一次"。
五、还差的:执行成功 ≠ 永远可靠
跑通这条链路,Runtime 能安全地执行一次有副作用的调用了。但这离"可靠"还有距离,几件事本篇点到为止,先立个边界:
- 一次超时不等于工具坏了。订单服务偶发抖动,不能立刻把
refund_order永久下线;连续失败达到阈值才考虑熔断。 - 熔断后的探测,不能用一笔新退款去试。要用无副作用的健康检查——否则"检查服务恢复没有"这件事本身,又制造了一笔业务动作。
- Runtime 不替代鉴权、审批和审计。工具能不能调,除了契约校验,还涉及"这个用户有没有权限"“要不要二次确认"“留不留审计留底”——这些是 Runtime 与领域服务的责任,本篇没展开。
这些都属于"从单次安全执行到生产级可靠"的延伸,先把边界划在这,不抢后续章节的戏。
到下一话,问题才真正变难
现在,Runtime 已经能把单个候选调用安全地执行,并在超时后不搞砸。
但它还不会根据第一次工具的结果,决定第二步做什么。
第五话才进入真正的 ReAct 循环:第二个 Action 必须依赖第一个 Observation。那时,今天建立的 ToolResponse 和幂等恢复,会成为循环里可复用的执行底座。

留言
欢迎分享你的想法。评论提交后会在审核通过后显示。