Code Agent 解剖(13):Harness 设计之三——工具执行管道

一次工具调用的旅程

模型输出了一个 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"),
    ...
)

灰名单(mvpip installchmod 等)走 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 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页

相关推荐
动物园猫1 小时前
行人细分目标检测数据集:3类别、4,000张图像 | 目标检测
人工智能·目标检测·计算机视觉
船厂电气自动化ai大模型1 小时前
AI大模型与数学·第57课 傅里叶全套工具链综合实战:串联级数/连续变换/DFT/FFT,图像、音频、扩散模型完整例题
数据结构·人工智能·深度学习·算法·机器学习
v:ychya20181 小时前
2026 外贸 GEO 实操:4 步让独立站被 ChatGPT 优先引用
人工智能·chatgpt
cd_949217211 小时前
角色模型用AI生成纹理靠谱吗,能直接用于游戏吗?
人工智能·游戏
YOLO数据集集合1 小时前
车牌目标检测数据集 | 车牌检测 智能交通 车辆识别 目标检测8003期
人工智能·目标检测·计算机视觉·车牌检测·非机动车车牌
IT_陈寒1 小时前
SpringBoot自动配置失效?你可能漏了这个小开关
前端·人工智能·后端
磁场转动100万匹1 小时前
OpenCV 答题卡识别判卷实战:从图像预处理到自动评分
人工智能·opencv·计算机视觉
cd_949217212 小时前
AI自动生成纹理能保持角色不同部位风格一致吗?
人工智能
人工智能培训2 小时前
人工智能数据安全下的个人信息保护实践方案
大数据·人工智能·算法·生活