Featured image of post 第四话|模型说调 refund_order,Runtime 怎么不把事情搞砸?

第四话|模型说调 refund_order,Runtime 怎么不把事情搞砸?

模型提出的工具调用不可信:Runtime 怎么校验、执行,并在超时后靠幂等状态避免重复退款。

系列 GYA(Get Your Agent) 第 4 / 12 篇
主题 AgentToolRegistryFunction-CallingRuntime

第三话结束时,我们留下一个问题没解决:

tool_calls 永远是候选,不是命令。谁来判断"能不能执行、怎么执行"?

这就是第四话要回答的。模型提出的工具调用,哪怕 JSON 合法、参数齐全,也可能是错的、越权的、有副作用的。Runtime 不能照单全收——它得先校验,再执行,而且要在执行出问题时不把局面搞得更糟。

这里最危险的,不是选错工具,而是一个有副作用、结果又说不清的操作。比如模型要调 refund_order 退款:

handler 执行到一半超时了。钱到底退了没有?直接重试,可能重复退款;不重试,用户又以为没退成。

这一话就沿着这条退款主线,讲清楚 Runtime 怎么把"模型说要调用"变成"安全地执行"——以及最关键的,超时之后怎么根据真实状态做决定,而不是靠猜。

一、模型说"调 refund_order",Runtime 先干什么?

模型返回的 tool_calls 里,是一段结构化申请单:工具名 refund_order、参数 order_ididempotency_key。但它是模型生成的,是外部输入,不是已经校验过的命令

Runtime 拿到它,第一件事不是执行,是问三个问题:

  1. 这个工具名,是允许执行的吗?
  2. 参数齐全、类型对吗?
  3. 这个工具现在可用吗?

任何一个不过,就不进 handler,而是返回一个结构化的错误,让上层能稳定地分支处理。

为什么要结构化,不能是一段"调用失败,请稍后再试"的文案?因为文案里没有可编程的信息——程序没法可靠地区分"工具不存在"和"参数缺了"和"执行到一半挂了"。而这三种情况,处理方式完全不同:

错误码发生了什么该怎么处理
UNKNOWN_TOOL模型报了个不存在的工具名拒绝,修正工具选择
INVALID_ARGUMENT参数不满足契约补参数或拒绝
TOOL_UNAVAILABLE工具已下线或不健康告知不可用,不能硬调
EXECUTION_ERRORhandler 或下游执行失败按恢复语义决定,见下一节

前三个错误,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 和幂等恢复,会成为循环里可复用的执行底座。

留言

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