一次工具调用的旅程
模型输出了一个 tool_call,比如 Edit("foo.py", ...),到最终结果写进 history------中间发生了什么?
这一篇沿着这条路径走一遍,把两个核心模块拆开看:ToolOrchestrator(调度层,管并发、管结果预算)和 ToolExecutor(执行层,管权限、管乐观锁、管熔断)。
结论先说
工具执行管道分两层,职责完全分离:
| 层 | 模块 | 负责 |
|---|---|---|
| 调度层 | ToolOrchestrator |
并发分组、顺序保证、结果预算截断 |
| 执行层 | ToolExecutor |
权限检查 → 乐观锁注入 → 熔断检查 → tool.run() |
模型给的是一批 tool_calls,Orchestrator 负责"怎么跑这批";Executor 负责"一个工具怎么安全地执行"。两层之间是清晰的接口边界。
一、调度层:并发分组,顺序保证
python
# tools/orchestrator.py ToolOrchestrator
SAFE_TOOL_NAMES = {"Read", "Grep", "Glob"} # 只读,可并发
UNSAFE_TOOL_NAMES = {"Edit", "Bash", "Task", ...} # 有副作用,强制串行
模型在一步里可能同时请求多个工具,比如同时 Read 三个文件再 Edit 一个。Orchestrator 的第一件事是分批:
python
# partition_tool_calls()
# 输入:[Read, Read, Edit, Grep, Grep, Edit, Read]
# 输出:[并发(Read,Read), 串行(Edit), 并发(Grep,Grep), 串行(Edit), 并发(Read)]
规则很简单:连续安全工具合并为一个并发批,遇到写工具就切断。
并发批用 ThreadPoolExecutor 执行,但有一个细节------结果顺序的保证:
python
def _run_batch_concurrently(self, batch, ...):
# submit 立即返回 Future,不阻塞------所有工具几乎同时开始
futures = {
offset: executor.submit(self._execute_plan, plan, ...)
for offset, plan in enumerate(batch.calls)
}
# .result() 阻塞等待,按 offset 存入 dict 保留原始位置
for offset, future in futures.items():
observations[offset] = future.result()
# 按 offset 顺序重建列表------线程完成顺序不定,这里强制恢复模型请求顺序
return [observations[idx] for idx in range(len(batch.calls))]
线程 B 可能比线程 A 先完成,但返回列表里 A 永远在 B 前面。模型请求的顺序就是 history 里写入的顺序。
串行批则是普通 for 循环:一个跑完再跑下一个,总耗时是所有工具时间之和,但保证没有竞态。
二、执行层:四关卡管道
每个工具的实际执行走 ToolExecutor.execute(),内部是一条线性管道,任一关卡失败立即短路:
scss
参数解析 → [关卡1] 权限检查 → [关卡2] 乐观锁注入 → [关卡3] 熔断检查 → tool.run()
关卡 1:权限检查
python
# tools/permissions.py RiskClassifier
# 决策优先级:
# 1. Read/Grep/Glob → ALLOW(只读,无风险)
# 2. Edit → 检查 runtime_mode(只读子 agent → DENY)
# 3. Bash → 正则黑名单 → 灰名单 → 白名单
# 4. 未知工具 → DENY(fail-closed)
Bash 的处理最复杂。黑名单命中直接 DENY,不用问用户:
python
_BASH_DENY_PATTERNS = (
(re.compile(r"sudo"), "sudo crosses the process privilege boundary"),
(re.compile(r"rm(?:\s|$)"), "destructive delete command"),
(re.compile(r"bash\s+-c"), "nested shell execution bypasses command classification"),
(re.compile(r"`|\$\("), "shell command substitution executes nested commands"),
...
)
灰名单(mv、pip install、chmod 等)走 ASK 策略,在当前 MVP 实现里 ask_policy="deny" 时 ASK 等于 DENY------这意味着风险未知的命令默认不执行,让模型换一种方式。
关键设计原则:fail-closed。进了工具 Registry 只意味着模型能"看见"这个工具,执行权还要过权限这道门。
关卡 2:乐观锁注入(仅 Edit)
python
# tools/executor.py
if name == "Edit":
parameters = self.registry.inject_optimistic_lock_params(name, parameters)
Read 工具执行后,框架会缓存该文件的 mtime + size。Edit 执行前,框架自动把缓存的 expected_mtime_ms 注入参数。如果文件在 Read 和 Edit 之间被外部修改了,Edit 会检测到 mtime 冲突,返回 CONFLICT 错误而不是静默覆盖。
这解决了一个微妙问题:模型读取文件后决定修改,但文件可能在这期间被用户或另一个工具改过。乐观锁让这类"写覆盖"变得可检测。
关卡 3:熔断检查
python
# tools/circuit_breaker.py
# 三态:CLOSED(正常)→ OPEN(禁用)→ HALF_OPEN(冷却后放行一次试探)
if not self.registry.is_available(name):
return self.registry.create_circuit_open_result(name, parameters)
工具连续失败 3 次(默认阈值)后,熔断器打开,工具临时禁用 300 秒。这防止了一个坏掉的工具反复重试、消耗 token 和步骤配额。冷却期结束后,熔断器进入 HALF_OPEN 状态,放行一次试探:成功则恢复 CLOSED,失败则重置计时继续 OPEN。
tool.run() 与异常兜底
python
try:
result = tool.run(parameters)
if not isinstance(result, ToolResult):
raise TypeError(...)
except Exception as exc:
# 所有未捕获异常在这里兜底,转为 EXECUTION_ERROR ToolResult
# 保证不向上抛出,loop 永远收到 ToolResult,不会因单个工具崩溃中断整个 agent
return ToolResult(status=ERROR, error_code=EXECUTION_ERROR, ...)
工具的任何内部异常都在这里被捕获,转成标准 ToolResult。这是工具管道的最后一道安全网:单个工具崩溃不会让整个 loop 崩溃。
三、结果后处理:两层字节预算
工具执行完之后,结果还要经过三轮后处理:
scss
执行结果
→ _normalize_empty_result() 空输出补占位文本
→ _apply_observation_limit() 按行数/字节初步截断
→ _apply_result_budget() 两层字节预算最终截断
两层预算防止工具输出撑爆 context window:
markdown
层 1(单工具上限,默认 50KB):
单个工具输出 > 50KB → force_truncate → 完整内容 spill 到磁盘文件
结果中附文件路径,模型可以按需引用
层 2(批次总量上限,默认 200KB):
全部工具截断后总量仍 > 200KB → 按大小降序,逐个强制截断,直到总量达标
贪心策略:优先截断最大的,减少截断次数
层 1 已截断的结果在 metadata 里标记 replaced=True,层 2 直接跳过,避免对同一结果二次截断。
四、生命周期事件:全程可观测
每个工具调用都会经历四个生命周期状态,全部发射事件到 trace/transcript:
requested → started → completed / failed
requested:模型请求了工具(参数解析前,无论成功与否先记录)started:通过权限检查,进入实际执行completed:执行成功(包括 partial status)failed:执行失败(包括权限拒绝、熔断、异常)
这四个状态让 trace 里能完整还原"一个工具调用的故事":它被请求了吗?被拒绝了还是真的跑了?跑了多久?结果是什么?
设计亮点
1. 调度与执行分离
Orchestrator 不关心单个工具怎么执行,只管"这批工具怎么调度";Executor 不关心有多少工具并发,只管"这一个工具安不安全"。职责分离使两层可以独立测试和演化。
2. 写操作强制串行,顺序与模型请求一致
并发执行后强制按原始 offset 重排结果------这不只是顺序问题,更是语义问题。模型发出 [Edit A, Edit B] 时预期的是 A 先 B 后,history 写入顺序错了会让下一步的模型理解混乱。
3. 多层安全边界
权限黑名单(规则层)→ 乐观锁(数据层)→ 熔断器(可用性层)→ 异常兜底(稳定性层),每层解决一类问题,互不重叠。
小结
| 设计选择 | 方案 | 工程价值 |
|---|---|---|
| 并发策略 | 只读并发、写操作串行 | 安全无竞态,且 Read/Grep/Glob 并发提升效率 |
| 顺序保证 | offset 重排 | 模型语义不被并发执行打乱 |
| 权限设计 | fail-closed + 正则黑名单 | 危险命令进不了 tool.run() |
| 乐观锁 | Read 缓存 mtime,Edit 自动注入 | 写覆盖可检测,不静默 |
| 熔断器 | 三态 + 冷却期 | 坏掉的工具不会反复重试消耗配额 |
| 字节预算 | 单工具 + 批次总量两层 | 工具输出不会撑爆 context window |
关于本系列的源码
本系列所有分析均基于开源项目 MyCodeAgent。
源码里已经按照本系列文章的讲解顺序,在关键位置加入了配套注释------读文章时可以对照代码,也可以直接克隆下来自己跑、改、扩展,基于它开发你自己的 agent。
bash
git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env # 填入你的 LLM API key
uv sync
uv run python main.py
欢迎访问 PrimeSkills ------ 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页