<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>ToolRegistry on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/toolregistry/</link><description>Recent content in ToolRegistry on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Tue, 14 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/toolregistry/index.xml" rel="self" type="application/rss+xml"/><item><title>第四话｜模型说调 refund_order，Runtime 怎么不把事情搞砸？</title><link>https://zh.renxinblog.cn/post/c04-tool-registry/</link><pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/c04-tool-registry/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/c04-tool-registry-cover-v2.png" alt="Featured image of post 第四话｜模型说调 refund_order，Runtime 怎么不把事情搞砸？" /&gt;&lt;p&gt;第三话结束时，我们留下一个问题没解决：&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;code&gt;tool_calls&lt;/code&gt; 永远是候选，不是命令。谁来判断&amp;quot;能不能执行、怎么执行&amp;quot;？&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;这就是第四话要回答的。模型提出的工具调用，哪怕 JSON 合法、参数齐全，也可能是错的、越权的、有副作用的。Runtime 不能照单全收——它得先校验，再执行，而且要在执行出问题时不把局面搞得更糟。&lt;/p&gt;
&lt;p&gt;这里最危险的，不是选错工具，而是&lt;strong&gt;一个有副作用、结果又说不清的操作&lt;/strong&gt;。比如模型要调 &lt;code&gt;refund_order&lt;/code&gt; 退款：&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;handler 执行到一半超时了。钱到底退了没有？直接重试，可能重复退款；不重试，用户又以为没退成。&lt;/p&gt;

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