状态:✅ 已实施(2026-07-16 同日拍板并落地,P0 + P1 全部完成)。
起因:beamai 的
ToolCallingManager有一套完整的三层超时处理,问「我们能否也对 tool 超时执行做处理」。本文的结论:能,而且必须做——但要做的不是 beamai 那三层。先读
design-principles.md(§1 无真实需求不建)。 执行引擎本身见tool-calling-manager-design.md。
beamai 的三层超时全部建立在 exit(Pid, kill) 这一个原语上——无条件、抢占式、
立即生效。JVM 没有这个原语(Thread.stop 已在 JDK 20+ 移除,调用即抛
UnsupportedOperationException)。只剩协作式中断。
照搬三层,我们买到的是文档,不是强制力。 一个 while(true){} 的工具,在 beamai
里 30 秒后必死;在 JVM 里,无论套几层超时,它都会一直跑到进程退出。
所以本文把 beamai 的方案拆成可移植与不可移植两半:
| beamai 的做法 | 能否移植 | 结论 |
|---|---|---|
| 超时优先级链:工具声明 > manager 缺省 > 内置缺省 | ✅ 纯策略,与进程模型无关 | 吸收(§3.2) |
超时归类 transient → 声明 :retry 的工具自动重试 | ✅ 我们已有等价分类体系 | 吸收(已具备,§1.3) |
| 层 1 每工具超时(spawn + kill) | ⚠️ 语义降级为「放弃等待」 | 吸收,但改语义并如实写明(§2) |
| 层 2 并发收集截止(gather deadline) | ❌ 理由是 BEAM 特有 | 不建(§3.4) |
| 层 3 批级隔离兜底(batch worker) | ❌ 理由是 BEAM 特有(link 传播崩溃) | 不建(§3.4) |
同时,分析暴露了两个我们自己的问题,与 beamai 无关,独立成立:
:timeout 是个死选项(真 bug)。deftool 接受它、不报错、然后完全忽略。
用户写 {:timeout 5000} 得到的是零。见 §1.2。timeout-filter 会悄悄毁掉 manager 的线程模型(真 bug)。它用 clojure.core/future
= send-off 池 = 平台线程。于是 VT 引擎的工具其实跑在平台线程上。见 §4。建议落地:P0 修死选项 + P1 让 timeout-filter 读 :tool/timeout 并跑在虚拟线程上。
两项都是修既有承诺,不是长新抽象——不触发 §1 的立项判据。
advisor.clj:243 timeout-filter —— future + deref 带超时 + future-cancel,
超时归类 :transient、不带 :writes(事务性)。这个 filter 是对的:位置对
(:tool 洋葱链包住每次工具调用)、形状对({:result :error},经 chat_client.clj:226
映射为 :value)、分类对(transient → 可被 :retry 重试)。
它是唯一的时间防线,而且是 opt-in 的:不挂就没有。
deftool :timeout 是死选项(真 bug)tool.clj:210 (some #{:sensitive :timeout :category ...} (keys (first body)))
:timeout 出现在 opts 白名单里——仅此一处。它决定「首个 map 算不算 opts」,
然后就没有然后了:
:tool/timeout(:tool/serial、:tool/retry、
:tool/return-direct 都有,独缺它)tool/timeout → 零命中timeout-spec 读取函数(retry-spec、serial-tool? 都有)后果:用户写 (deftool foo "..." [] {:timeout 5000} ...),编译通过、无警告、
运行时完全没有超时。这是最坏的一种失败——沉默的谎言。白名单里列着它,
等于 API 文档承诺了它。
已实测证实(2026-07-16,非推断):
(tool/deftool slow-tool "睡 3 秒" [] {:timeout 500}
(Thread/sleep 3000) "done")
(tool/invoke #'slow-tool {})
;; 声明 500ms 超时,实际耗时 3000ms,结果 {:success true, :result "done"}
;; ^^^^^^ 跑满全程,:success true —— 超时零效果
var 元数据实际产出(注意 :tool/timeout 不在其中):
:tool/category :tool/context :tool/function :tool/params :tool/retry
:tool/return-direct :tool/schema :tool/sensitive :tool/serial :tool/tags
对照 beamai:tool_spec.timeout 是强制执行的(beamai_tool.erl:186 resolve_timeout/2)。
我们抄了字段名,没抄行为。
:transient | :semantic | :environment,与 beamai 的
beamai_tool_error:classify/1 同构。超时归 :transient 已是 timeout-filter 的现状行为。react.clj:75 invoke-with-retry 包在 chat-client/invoke-tool 外面,
故 filter 链(含超时)在重试循环内部——超时 → transient → 重试。
与 beamai 的 invoke_with_retry/6 语义一致。这条链路已经是通的,
只要超时能真的发生。(.get f) 无限阻塞react.clj:160 (mapv (fn [^Future f] (.get f)) futs)
无超时参数。一个卡死的工具 → 整个 agent 循环永久冻结,无外部逃生口。
且 execute-batch-via(react.clj:205)在三种情况退化为内联执行:
(if (or (nil? executor) serial? (<= (count tool-calls) 1))
(run-inline ...) ; ← 连 Future 都没有,从外部无法施加任何超时
(run-on-executor ...))
注意 (<= (count tool-calls) 1):单工具调用走内联——而单工具调用是最常见的
情形。所以「批级超时」这类方案会恰好在绝大多数调用上失效。这一条直接否掉了
「在收集点加超时」的思路(§3.4 方案 B)。
kill vs abandon(本文的重点)这一节是整份文档的支点。如果只读一节,读这节。
%% beamai_tool.erl:427 call_handler/4
after Timeout ->
exit(Pid, kill), %% ← 无条件。进程立即死亡,内存回收,无需配合。
{error, #{class => timeout, reason => tool_timeout, ...}}
exit(Pid, kill) 是不可捕获、不可延迟、不需要目标配合的。beamai 的注释说得很准:
为什么必须起进程:BEAM 无法中断同进程内的内联调用——要让 `timeout' 声明 真正可执行,handler 就得跑在一个可以 kill 的进程里。
关键词是**「真正可执行」(enforceable)。BEAM 的 timeout 声明是强制力**。
| 手段 | 状态 |
|---|---|
Thread.stop() | JDK 20+ 直接抛 UnsupportedOperationException。不存在了。 |
Future.cancel(true) → Thread.interrupt() | 只设一个标志位。目标不看,就没事发生。 |
能被 interrupt 打断的:Thread/sleep、Object.wait、BlockingQueue、
InterruptibleChannel、lockInterruptibly。
打不断的:
synchronized 块上的等待、native/JNI 调用InterruptedException 的代码;工具自己 spawn 的平台线程⚠️ 已修订(2026-07-16,MiniMax live 实测推翻)
本节原先还列了一条:「
InputStream.read()读普通 socket——绝大多数 HTTP 客户端就长这样。这条最要命:『调用外部 API 的工具』正是最需要超时的那类, 也正是打不断的那类。」实测(JDK 25.0.2)证明它对我们的实现不成立:
场景 interrupt 后 虚拟线程 + socket read()抛 SocketException: Closed by interrupt✅ 真被取消平台线程 + socket read()读照常完成,interrupt 被完全忽略 CPU 忙循环(不检查标志) 继续跑到自然结束 原因:JDK 13+ 把
java.net.Socket重实现在 NIO 之上(JEP 353),在虚拟线程 上会响应 interrupt 并关闭 socket。而 §3.3 的 P1-2 恰好把工具搬上了虚拟线程 ——于是「阻塞 IO 打不断」这条对我们的实现是错的。原判断是平台线程时代的 常识,写文档时没有实测,是 live 测试逮到的 (examples/tool_timeout_live_test.clj场景 1)。后果:P1-2 的价值被本文低估了。它不只让被放弃的执行变便宜(§2.4 的几 KB 栈),更把最常见的工具形态(阻塞 IO / HTTP 调用)从「打不断」变成了「真能 取消」——超时后 socket 立刻关闭,请求真的中止,副作用不会落地。 这条如今是选虚拟线程而非平台线程池的首要理由。
§2.3 的结论本身不变(超时仍是「放弃等待」而非「终止执行」,仍无 kill 原语),但残余风险的范围大幅收窄:从「所有阻塞调用」缩到上面那三行—— 主要是不检查中断标志的 CPU 密集代码。已由 live 场景 4 钉住(真机实测: 超时上报 t=2361ms,副作用落地 t=4059ms,晚 1.7 秒,其间模型已在作答)。
必须诚实命名。超时后:
interrupt。阻塞 IO(socket read 等)会真的中止——虚拟线程上 JDK
关闭 socket,请求作废、副作用不落地 ✅(§2.2 修订块,live 场景 1 实测)最后一条是语义问题,不是实现瑕疵:{:result "工具调用超时" :error {:class :transient}}
不带 :writes(事务性,对的),但工具函数体里的外部副作用(写库、发消息、扣款)
框架管不着。beamai 的 kill 把这个窗口压到零;我们压不掉——但也远没有本文初稿
以为的那么宽:常见的「工具卡在外部 API 上」实际能干净取消,剩下的主要是纯计算
卡死。
这必须写进 docstring,不能让用户以为挂了超时就等于工具停了。
「声明 :retry 即承诺幂等」在我们这里比在 beamai 更重要——因为超时重试时,
上一次调用很可能还在跑。
文档纪律:beamai 的文档可以写「到点 kill 执行进程」。我们的不可以。 照抄它的措辞会构成一个 API 谎言——比不做更糟。
既然超时 = 放弃,被放弃的线程就是泄漏。代价随引擎不同,差别很大:
| 引擎 | 被放弃的工具 | 代价 |
|---|---|---|
VirtualThread | 泄漏一根虚拟线程 | 低——几 KB 栈,不占 OS 线程 |
ThreadPool(有界池) | 永久占用一个池槽 | 高——舱壁逐格坏死,池最终整个死掉 |
Sequential | 无 Future,压根无法放弃 | 不适用——调用方陪着一起挂 |
反直觉之处:ThreadPoolToolCallingManager 的舱壁隔离,恰恰是最经不起超时泄漏的
引擎。虚拟线程引擎被放弃 100 个工具只是多几百 KB;有界池被放弃 pool-size 个工具就
彻底死了,且不会恢复。
§2.2 修订后,本表的差距还要再拉大一档:虚拟线程不只让泄漏更便宜,还让 阻塞 IO 类的泄漏根本不发生(socket 被关、线程正常结束)。同一个卡在外部 API 上的工具,跑在虚拟线程上会干净消失,跑在平台线程上则连 interrupt 都无视、 池槽真的永久损失。这是 §4 那个 bug(
timeout-filter曾把工具搬去 send-off 平台线程池)比初判更严重的地方——它同时毁掉了取消能力和线程模型。
这一条加强了「超时逻辑要跑在虚拟线程上」的结论(见 §4)。
⚠️ 终态修订(2026-07-16 用户拍板):缺省不超时,且缺省来源是引擎而非 filter
本章成文时,「整体缺省」的载体是
advisor/timeout-filter(§3.3 的 P1)。已变:工具声明 deftool {:timeout ms} > 引擎缺省 (…-tool-calling-manager {:timeout ms}) > **不超时**两条改动:
- 缺省不超时——框架不替调用方决定何时放弃。都没给就不起线程、零开销。 (与 beamai 同日的动作一致:它把三层缺省全改成了
infinity,见 §5.5。)- 缺省超时属于引擎,不属于 filter——manager 就是执行引擎(线程模型 + 隔离 边界 + 调度策略),「这批工具最多跑多久」是执行策略的一部分。这正是 beamai
manager_opts.tool_timeout的立场:「manager 是执行引擎,时间上限属于执行策略, 故在此配置——而非散落在 app env 或逐个工具声明」。本文原方案把它放在 filter 里,就是那句话说的「散落」。两者都由
chat-client/invoke-tool强制(§3.5 修订块),开箱即生效。
timeout-filter已删除(同日,用户拍板)。它曾短暂退为「第三层兜底」,但 按 §1 四问过秤:它的两个职责(强制力、整体缺省)已分别被invoke-tool与引擎:timeout接管,剩下的唯一辩护「按工具名/标签动态决定时限」是假想需求—— 用户真要时自己写 5 行:toolfilter 包tool/call-with-timeout即等价。 三层优先级也是复杂度税(beamai 同样只有两个来源:tool_spec + manager opts)。build-func-def的:timeout字段随之删除(唯一读者就是该 filter—— 没有读者的字段就是下一个死选项)。已入 check_docs 墓碑,防文档复活。 机制本体tool/call-with-timeout保留(invoke-tool消费)。下文 §3.1–§3.4 的分析与否决(层 2 / 层 3、方案 B)全部不受影响:它们讲的是 「要不要复刻 beamai 的层数」,与缺省值放哪儿正交。文中其余提及
timeout-filter的段落均为历史记录(它曾是 P1 的载体与两个真 bug 的现场), 按「设计文档是历史记录」的约定保留原文。
目标:让「工具声明超时」这件事真的发生,且在三个引擎、两条路径(内联 / executor) 上行为一致。
非目标:真正终止工具执行(JVM 做不到,§2.2);复刻 beamai 的层数(§3.4)。
:timeout 死选项(真 bug,独立于 beamai 成立)现状是沉默的谎言(§1.2)。三条出路,只能选一条:
| 做法 | 评价 | |
|---|---|---|
| a | 发出 :tool/timeout 元数据 + 加 timeout-spec 读取函数 + 由 timeout-filter 消费 | ✅ 推荐。与 :retry/:serial 完全对称——它们都是「声明在 deftool,由上游消费」 |
| b | 从白名单删掉 :timeout | 可接受。诚实,但把已在 API 面上的字段拿走 |
| c | 保持现状 | ❌ 不可接受。沉默 no-op |
选 a。:retry 就是现成的先例:tool.clj:351 retry-spec 读元数据,
chat_client.clj:163 retry-policy 归一化,react.clj:75 消费。:timeout 照抄这条路径即可。
timeout-filter 读 :tool/timeout + 跑在虚拟线程上两处改动,都在 advisor.clj:243:
每工具超时优先级(移植 beamai resolve_timeout/2):
工具声明 :tool/timeout > filter 构造时的 timeout-ms > 不超时
工具声明最优先——理由与 beamai 完全一致,且与进程模型无关,纯策略,可移植:
工具最清楚自己要跑多久。filter 的 timeout-ms 降级为缺省值,
对应 beamai 的 manager 级 tool_timeout。
req 里已有 :function → {:name :schema :sensitive}(chat_client.clj:176
build-func-def)。注意:build-func-def 目前不透传 timeout,需一并补上
(或由 filter 自己查 var)。inline tools(chat_client.clj:210)无 var、无元数据,
只能吃 filter 缺省值——这是既有的 inline 盲点,与 :sensitive 现状一致,不新增。
不要用 clojure.core/future(§4 的 bug):改用虚拟线程,
使被放弃的执行代价维持在 §2.4 的「低」档,且不破坏引擎的线程模型。
改完后,「所有工具 30 秒缺省 + 个别工具自己声明」= 挂一个 filter + 各自 deftool
写 :timeout。这就是 beamai 层 1 的全部价值,且是 opt-in 的。
层 2(gather deadline,beamai_agent_utils.erl:200)——并发收集全局截止,
超时 kill 未完成 worker、保住部分结果。
层 3(batch worker,beamai_tool_batch_worker.erl)——批级兜底,
worker 崩溃或超时 → 整批合成 error + context 回滚。
为什么不移植——它们的理由是 BEAM 特有的,到我们这里全部落空:
层 3 的主要理由是「防止工具带崩调用者」,而这在 JVM 根本不存在。beamai 的原文:
工具内
spawn_link的子进程崩溃 → 退出信号绕过 try/catch 直接打死宿主进程
JVM 没有 link,没有退出信号传播。 子线程抛异常打不死父线程。
invoke-one 的 try/catch 就是本批的错误边界。层 3 在防一个我们没有的问题。
⚠️ 修订(2026-07-16 code review #5):本条原文写「
invoke-one的 try/catch 已经是完备的错误边界」——当时并不完备:它只catch Exception,于是Error全部逃逸,一个工具的深递归StackOverflowError会打死整个 agent 循环。 这是我在论证「不需要层 3」时对自家防线的过度自信:结论(不移植层 3)不变 ——层 3 防的是 BEAM 的 link 传播,我们确实没有——但支撑它的「边界已完备」这条 事实当时是错的。已修:四个 catch 点(tool/invoke、chat-client 的 inline/var terminal、invoke-one)改收Throwable,非致命 Error 收敛为该工具的错误结果, 致命的(OOM 等,判据见model.error/fatal-throwable?)仍原样上抛。 现在这句话才成立。
层 3 的另一半理由(批级时间兜底)在我们这里会打偏。beamai 的注释说
「串行路径没有内层防线,本层是其唯一的时间防线」——但我们的串行路径是
真·内联(run-inline,压根不构造 Future)。要从外部给它加时间兜底,
就得把它挪上线程,而「从不构造 Future」是 SequentialToolCallingManager
被明文写进文档的本质属性(react.clj:281:「不背 ExecutionException 包装与
Future.get 的中断语义——这是它与『给 VT 引擎塞一个 same-thread executor』的
本质区别」)。为了一个打不断的超时,毁掉一个引擎的定义属性,不划算。
层 2 的价值(保住部分结果)我们免费就有。beamai 需要 gather deadline 来在 全局截止时保住已完成工具的结果;而我们每工具的超时发生在 filter 里 —— 超时的工具自己变成一条 transient 错误结果,其它工具毫发无伤地正常返回。 部分结果是 §3.3 方案的自然结果,不需要专门一层。
层 2 + 层 3 的宽限期(grace)纯属自找的复杂度。beamai 必须小心让内层先响、
外层后响,否则外层会把内层本可保住的部分结果整批冲掉——它为此专门写了一大段
注释和一个 tool_batch_grace 配置。这个问题是「有多层」制造的。我们只做一层,
问题不存在。
四问判据(design-principles.md §1.2)逐条过:
| 问题 | 层 2 / 层 3 |
|---|---|
| 现在有人要用吗? | ❌ 没有具体场景。是「beamai 有,我们对称一下」 |
| 不建的话用户怎么办? | 挂 timeout-filter(一行)即等价 → §1.2 的「假想」列 |
| 换来的是什么? | 只是「更像 beamai」。没有新能力 |
| 触发条件写得出来吗? | 写不出。真出现「批级预算」需求时再谈 |
四问全落在假想列 → 不立项。 这与 tool-calling-manager-design.md 毙掉
:backend / PR2 / PR3 是同一条判据。
真需求长什么样:若将来出现「整轮工具预算 ≤ N 秒,超了就把这轮全部作废」的 具体调用方——那是批级预算需求,不是超时兜底需求,届时按其真实形状设计。
tool/invoke 或 run-on-executor为完整起见,记下另外两个被否的位置:
run-on-executor 的 (.get f) 加超时——只覆盖 executor 路径。
§1.4 已证明单工具调用走内联,即最常见的情形完全不设防。
「在最常见的路径上失效的保护」比没有保护更危险,因为它看起来像有。tool/invoke 内部恒定起线程(对应 beamai call_handler 的位置)——
这是 beamai 的选择,且能覆盖所有路径。但在 JVM 上:给每个工具调用无条件加一层
线程 + Future 包装,换来的是一个打不断的超时。beamai 敢这么做是因为
spawn 约等于免费且 kill 真的有效。两个前提我们都不满足。
→ 走 filter(opt-in),不进 tool/invoke(恒定)。⚠️ 已修订(2026-07-16 code review):方案 C 的否决只对了一半
上面这条把两件事捆在了一起否掉:位置(下沉到调用原语)与恒定(每个调用 无条件起线程)。该否的只有后者。
Review 逮到的真问题(#4):把强制力放在 filter 里,使
:timeout成了唯一 需要用户手动挂 filter 才生效的deftool选项——:serial/:retry/:return-direct都由 chat-client/react 直接消费、开箱生效。用户写下{:timeout 5000}不挂 filter → 编译通过、无警告、零效果,与 §1.2 那个死选项 bug 的可观察症状 逐字相同。我们只是把「白名单收下但没人读」换成了「元数据发出但没人读」。现在的落点:
chat-client/invoke-tool,且只在声明时起线程。 (⚠️ 本块写作时包裹的是整条 filter 链——又被 P9 review 修正为只包 terminal, 见 §5.6。「落在 invoke-tool、声明才起线程」两点仍准确。)
- 与方案 C 的差别是条件化:未声明 → 零开销,不起线程,与从前逐字相同。 §3.5 否的「无条件给每个调用加线程」依然否着。
- 与 react 的差别是职责:
:retry/:serial/:return-direct都是循环 / 批次策略(重试几次、能否并发、要不要回灌),属 react;而:timeout界定的是 单次工具调用的时间上限,那正是invoke-tool自己的事。放对层的收益是 不需要任何协调:谁调 invoke-tool 谁就拿到超时(react / 手搓循环 /run-tools/ 直调皆然)。 (实施中一度放进 react,随即发现chat-client/invoke-tool直调就没有超时了—— filter 已让位、react 又不在场。放错层的代价就是要发明协调。)timeout-filter因此退为纯缺省:只管没声明的工具,见到声明即让位。 优先级「声明 > filter 缺省」由让位实现,而非比大小——同一个 deadline 不套两层线程。另外,方案 C 否决理由里的「换来一个打不断的超时」这句本身也已被 §2.2 修订块 推翻(虚拟线程上阻塞 IO 真能取消)。一条否决记录的两条论据都倒了,剩下的那条 (不要无条件加线程)仍然成立,且已单独兑现——这正是
design-principles.md§1.3「已建的抽象,论据倒了一条 → 重新称剩下的论据有多重」的一次实践。
timeout-filter 正在悄悄毁掉线程模型(真 bug)advisor.clj:252:
(let [f (future (chain req)) ; ← clojure.core/future
r (deref f timeout-ms ::timeout)]
clojure.core/future 跑在 Agent/soloExecutor(send-off 池) —— 一个无界的
平台线程缓存池。而 chain 里包着其余 filter + 工具函数体本身。
后果,两个都不轻:
VT 引擎名不副实。VirtualThreadToolCallingManager 提交一根虚拟线程 →
虚拟线程立刻阻塞在 deref 上 → 真正的工具函数体在 send-off 池的平台线程上跑。
挂了 timeout-filter,「每调用一根虚拟线程」这句 docstring 就不再成立。
ThreadPool 引擎的舱壁对「被放弃的工具」失效。存活任务的并发度仍受 pool-size
约束(每根池线程阻塞在一个 deref 上,最多 pool-size 个在途),
但超时后池线程被释放、send-off 线程留下继续跑——被放弃的工具于是在
send-off 池无界堆积,且是平台线程(每根 ~1MB 栈)。
舱壁 bound 不住它们。这与 §2.4 的结论叠加:本就最怕泄漏的引擎,泄漏得最不受控。
修法:timeout-filter 内部改用虚拟线程(Thread/ofVirtual)而非 future。
被放弃的执行退回 §2.4 的「低」档,且 VT 引擎的 docstring 重新成立。
| # | 改动 | 文件 | 性质 |
|---|---|---|---|
| ✅ P0-1 | deftool 发出 :tool/timeout 元数据(四个 defn 分支各一处) | tool.clj | 修死选项 |
| ✅ P0-2 | 加 timeout-spec 读取函数(对称 retry-spec) | tool.clj | 修死选项 |
| ✅ P0-3 | build-func-def 透传 :timeout 供 filter 读取 | chat_client.clj | 修死选项 |
| ✅ P1-1 | timeout-filter 按 :function :timeout > timeout-ms 解析 | advisor.clj | 移植 beamai 优先级链 |
| ✅ P1-2 | timeout-filter 改用 Thread/startVirtualThread,弃 clojure.core/future;下游异常原样重抛(不再包 ExecutionException) | advisor.clj | 修 §4 的 bug |
| ✅ P1-3 | docstring 如实写明「放弃等待 ≠ 终止执行」+ 副作用窗口 + :retry 幂等前提(deftool 元数据说明、timeout-spec、timeout-filter 三处) | advisor.clj、tool.clj | §2.3,不可省 |
| — | ~~gather deadline~~ | — | ❌ §3.4 否决 |
| — | ~~batch worker 兜底~~ | — | ❌ §3.4 否决 |
测试(+9 tests / +29 assertions,全套 309/1256/0),逐条对应 §3/§2 的论点:
:timeout 声明生效:元数据回归钉(tool_test/timeout-option-emits-metadata)+
端到端穿 build-func-def → filter(advisor_test/tool-declared-timeout-end-to-end-test,
修复前此用例睡满 60s);另钉「声明本身无强制力,裸 invoke 不超时」防误解为内置timeout-filter-priority-test).isVirtual 断言)+ 异常原样重抛
(timeout-filter-thread-model-test):transient → :retry 重试整链(react_test/timeout-transient-retry-test):writes(既有用例 + 端到端断言)react_test/timeout-partial-batch-test:慢者超时、快者原样返回、
:errors 只含超时者)timeout-abandons-not-kills-test),
把 §2.3 钉进测试,防止后人误以为有 kill 语义examples/tool_timeout_live_test.clj。慢后端是本地裸 TCP(零依赖不联网),
只有 LLM 是真的——分工:live 验「真实模型 + 真实阻塞 IO」,单测验形状与分支。
四个场景各自只验单测证明不了的东西:
get-price 声明 :timeout 6000 拿到真实数据(SKU-7 price: 70 CNY),
get-stock-age 未声明被杀(工具调用超时(800ms))。优先级在真实循环里
的可观察后果:模型的答案因此分成「一个报价 + 一个报超时」。:retry 透明重试:工具实跑 2 次,模型只看到成功结果、只有 1 条
tool result——重试对模型透明在真机成立。跑真机推翻了一个判断(详见 §2.2 修订块):初稿断言「socket read 打不断, 这条最要命」——实测虚拟线程上它真被取消。原判断是平台线程时代的常识, 写文档时没实测。P1-2 的价值因此被本文低估。
一处 flake 及其教训:场景 2 初版断言 (= 2 (count tool-calls-made)),
偶发失败——模型看到超时后自行重试了 get-stock-age,记录变 3 条。
重不重试是模型的自由,不是我们的机制;改为按名分组、断言「每次调用的结果形状」,
不钉次数。这正是「断言钉机制、不钉模型行为」这条规矩的又一个实例。
本文写作当天,beamai 侧把三层的缺省全部改为 infinity(工具级
?DEFAULT_TOOL_TIMEOUT、层 2 tool_gather_timeout、层 3 batch_timeout 均不再有
缺省截止),超时从「缺省 30s/120s/+5s grace」变为纯 opt-in 声明;层 3 的自我定位
明确退为「只管隔离,不替调用者决定何时放弃」。
这从 beamai 侧独立佐证了 §3.4 的判断:层 2/层 3 的价值从来不在缺省截止时间, 而在 BEAM 特有的隔离与 kill——前者(缺省值)连 beamai 自己都撤了;后者(隔离) JVM 不需要、(kill)JVM 给不了。两个体系各自收敛到同一形态: 超时是声明出来的策略,不是框架强加的缺省。 (注意本文 §1–§3 引用的 beamai 缺省值描述的是分析时点的代码,已过时,判断不受影响。)
落地当天的第二轮 code review(8 finder 角度 → 35 候选 → 3 个独立验证 agent, 10/10 CONFIRMED)推翻了本文记录的两个实现决定。修复已全部落地并实测复核 (TASK P9 有逐条账目),此处只记架构事实的变更,供读本文理解现状:
§3.5 修订块曾写「强制力落在 chat-client/invoke-tool(run-chain)」——包裹的是
整条 tool filter 链。这是错的层:approval-filter 的人工审批等待被算进工具的
超时预算(操作员敲 y 慢一点,工具从未执行就报超时;:retry 还会对人重复弹框);
且超时结果在链外合成,任何日志/指标 filter 永远观察不到超时。
现状:exec-with-timeout 在 terminal 内计时——filter 链在计时区外,
审批等待不吃预算;超时结果在链内产生,外层 filter 照常可见。beamai 也只计时
handler,本该一步到位。
effective-tool-timeout 曾读 (:tool-manager chat-client)——反向指针。后果:
instrumented wrapper(reify 委派)的内层引擎 :timeout 静默失效;独立 manager
直接跑批次时自己的 :timeout 被忽略。
现状:三个内置引擎在 execute-tool-calls 入口 binding
tcm/*active-manager-timeout* 为自身值(经 bound-fn* 传导到 executor 路径,
在 delegate 边界自然断开——与设计原则 §3 两个方向都吻合);chat-client 字段仅作
直调 invoke-tool 时的回落。是的,这是一个动态变量机器——与被 §1 毙掉的
*active-pools* 同类,但这次立得住:它服务的是验证过的真实 bug,不是假想需求。
校验补全(引擎侧 :timeout 与 :retry 都进装配期,0/"5s" 不再溜到执行期);
delegate/fanout 的 :timeout 上报为 inline 声明 + run-sync 以 try/finally 保
drop!(其内先 kill!)——引擎超时打断子 agent 时不再泄漏注册表、不再烧 token;
executor 路径 .get 拆 ExecutionException 取 cause,OOM 恢复原样逃逸(引擎不再
改变致命错误的可观察类型);Sequential/ThreadPool 的线程模型 docstring 诚实降级
(声明了超时的工具跑在 VT 上,是例外且写明);ThreadPool 的「占池槽」因果翻正
(真占槽的是无超时的卡死工具)。
教训与 §5.1 同款:每一步局部全绿,但「包链还是包 terminal」「字段还是协议/ 传递」两个隐含面当时没被追问——是 review 的多角度 fan-out 逮住的,不是测试。
§1.2 那个「deftool :timeout 是死选项」的真 bug,根因在本文里已经写过:
:serial / :retry / :return-direct 各有 inline 分支,独 :timeout 漏了。
但当时只补上了漏掉的那一支,没动为什么会漏——四个查询函数各自手抄同一段
(if-let [v (get (:tool-vars chat-client) fn-key)]
(读 var 的 :tool/* 元数据)
(查 inline-meta))
四遍。抄第四遍时漏一支,是这种结构的必然而非偶然。
现在两个来源在装配期汇成一张 ToolMeta 表(:func-def / :serial /
:retry / :timeout / :return-direct),四个查询退化成一次 map 查找 + 一次
字段读,签名不变。:retry 的默认值 merge 也从每次查询挪到装配期。新增字段
只需在建表的两个构造函数里各加一行,不再有「第五处忘了改」的位置。
合表顺带暴露了一个更深的不一致:那四处双分支是 var 优先,而
invoke-tool 的执行分派一直是 内联优先——同名时会「按 var 的超时/重试策略
执行内联的 handler」。这是本文 §1.2 那个 bug 的同族:不是某一支写错了,是同一件
事有两个独立的真相源。
处置不是选一个赢家,而是不许重名:build-chat-client 装配期直接拒绝同名工具
(var 之间、内联之间、两者之间都算,抛 {:duplicates [...]})。理由与本文
§3.1 的取舍同源——同名工具没有合理用例,只有坏结果::tools schema 列表里两份
定义都会发给 LLM(模型看见两个同名工具),而 tool-meta / inline-handlers
只留得下一个,「模型看到的」与「实际执行的」就此对不上,且没有任何运行期症状
可查(工具照常执行,只是执行的可能不是模型以为的那个)。「选一个赢家」是在给
配置错误编造语义。要替换某个工具,调用方该在传 :tools 之前处理自己的列表。
校验顺序也是有讲究的:重名校验排在 validate-tool-timeouts! /
validate-tool-retries! 之前——重名时 var-map / inline-handler-map 的
into 已经把重复悄悄吃掉了,此后校验的是「幸存者」,等于在错误的地基上校验。
有测试钉住这个顺序(两个同名内联工具且都带非法 :timeout,断言报的是重名)。
本文其余部分的
:timeout语义一字未变:优先级链(工具声明 > 引擎缺省 > 不超时)、放弃等待≠终止执行、只包 terminal 不包 filter 链,全部照旧。改的只是 「这些声明存在哪、怎么查」。
beamai 的三层超时是 exit(Pid, kill) 的三次运用;JVM 没有这个原语,照搬只能得到
三层文档。真正可移植的是它的策略(优先级链、transient 分类)而非它的结构
(层数、隔离边界)——后者是 BEAM 进程模型的倒影。
而这次分析真正的收获,反倒不在 beamai:**我们的 :timeout 是个躺在 API 面上的死选项,
timeout-filter 在悄悄把虚拟线程换成平台线程。**这两个问题不需要任何对标就该修。
而 live 实测又给这个总结补了一刀:第二个 bug 比初判严重——它不只毁线程模型, 还毁取消能力。虚拟线程上 JDK 会为 socket read 响应 interrupt(§2.2 修订块), 所以「工具卡在外部 API 上」这个最常见的形态,跑在虚拟线程上能干净取消、跑在 send-off 平台线程上则连 interrupt 都无视。修 §4 那个 bug 的同时,我们顺手把 「超时对最常见工具形态真的有效」这件事一起买回来了——这是写文档时没预见、 靠真机才发现的。教训照旧:没实测的性能/语义断言,就是一条没被证伪的猜想。
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |