云计算百科
云计算领域专业知识百科平台

Harness Engineering:把随机的模型关进确定性的壳子里

Harness Engineering:把随机的模型关进确定性的壳子里

2026-09 Google 在 ADK 2.0 与 Antigravity SDK 的公开阐述里把 Harness Engineering(确定性外壳工程)摆到了和模型能力同等的位置,Anthropic 同期发布的商务 Agent 蓝图给出了三条可直接抄的实践:动态 Skill 加载配合 Prompt Caching 压 TTFT、UI 组件作为工具输出、在 harness 层强制安全拦截且不可逆交易必须人工签署。本文的前提立场很硬:LLM 是带温度采样的非确定性 token 生成器,绝不能被授予对生产 OS 与数据库的中介化访问权。围绕这个立场,文章拆出四条确定性边界(编排控制器的步数与终止条件、隔离执行沙箱、状态持久化与回滚、以编译器/linter/单测为不可通融裁判的确定性断言),给出一套自动化修复循环:生成代码丢进沙箱跑测试,失败则把压缩后的精确错误栈回喂模型,固定迭代上限,人不再逐行 review。实操部分是一份完整可跑的 Python harness,含 subprocess 资源限制、临时目录、超时、git 快照回滚、pytest 断言、步数预算与成本熔断,能把「修一个 bug」跑成闭环;另外给出结构化输出 schema 强制、生成/评估/修复三分立、以及用零 LLM 调用的确定性代码替换 Schema Validator 与 Documentation Builder 的具体写法。

关键词:Harness Engineering、确定性边界、沙箱隔离、git 快照回滚、成本熔断、结构化输出、Google ADK 2.0、Anthropic Agent 蓝图

一、写在前面:为什么现在必须聊这件事

先把立场说清楚,后面所有设计都从这句话推出来:LLM(Large Language Model,大语言模型)是一个带温度采样的非确定性 token 生成器,它不应该被授予对生产 OS(操作系统)或生产数据库的中介化访问权。

「中介化访问」这个词是有意的。不是说模型不能碰生产,而是说模型不能成为人和生产之间那条唯一的、不可审计的通道。你让 Agent 拿到一个 execute_shell 工具,它就有了中介化访问:它能读文件、能写文件、能 curl、能 rm -rf。这条通道上发生的一切,在模型输出 token 的那一刻之前是不可观测的,在之后是不可回溯的。这两件事叠在一起,就是事故。

2026 年 8 到 10 月这一批公开材料里,最有价值的不是某个新模型的跑分,而是 Google 和 Anthropic 几乎同时把注意力移到了模型外面那一层壳。2026-09 前后,Google 在 ADK 2.0(Agent Development Kit,智能体开发套件)和 Antigravity SDK 的公开阐述中反复讲 Harness Engineering——把 Agent 的可靠性问题从「让模型更聪明」改写成「让壳子更硬」。Anthropic 同期发布的商务 Agent 蓝图则给出了三条非常工程化、可以当天就抄进项目的实践:

  • 动态 Skill 加载 + Prompt Caching(提示词缓存)压 TTFT(Time To First Token,首 token 延迟)。不要把所有 Skill 的定义一次性塞进 system prompt,而是按需挂载,并把挂载后的前缀做成可缓存的稳定前缀。
  • UI 组件作为工具输出。工具返回值不只是文本,可以是结构化的组件描述,让前端直接渲染成一个表格、一个 diff 视图、一个确认按钮,把「模型说什么」和「人看到什么」解耦。
  • 在 harness 层强制安全拦截,不可逆交易(转账、下单、发邮件、删资源)必须先生成一份待签署的提案,由人显式签署后才执行。
  • 第三条尤其值得划重点:**安全拦截放在 harness 层,而不是放在提示词里。**提示词里的「请不要删除生产数据」是一条建议,harness 层的「这个工具在这条路径上根本没有被注册」是一条法律。两者不是一回事,混淆它们是 2025 年那一代 Agent 最常见的翻车姿势。

    这篇文章要解决的,是把上面这些原则落成能跑的代码。我见过太多团队的 Agent 项目卡在同一个位置:demo 很漂亮,一上生产就不敢开权限;一开权限就出事;出事之后把权限收回来,于是又变回 demo。这个死循环的出口不在模型侧,在壳子侧。

    1.1 一个反直觉的结论:可靠性不来自模型,来自裁判

    大部分人对「提升 Agent 可靠性」的第一反应是换更强的模型、调更低的 temperature、写更长的 system prompt。这三件事我都做过,成本很高,收益很薄。真正带来量级变化的是第四件事:给 Agent 找一个它无法说服、无法绕过、无法讨价还价的裁判。

    编译器是这种裁判。linter 是这种裁判。单元测试是这种裁判。它们的共同特点是:判据是确定性的,判完之后给的是精确的错误位置,而且模型没法通过「措辞更好」来改变判决结果。当你的循环里有了这样一个裁判,模型的能力上限就不再是瓶颈,因为跑不通就是跑不通,模型只能改自己的代码,不能改裁判。

    这就是 Harness Engineering 的核心:把随机的生成器关进一个确定性的壳子里,让壳子决定什么算成功、什么算失败、失败之后还能不能再来一次。

    1.2 本文的边界

    本文不讲 Agent 的规划算法,不讲多 Agent 的通信协议(那是 MCP 与 A2A 的话题),也不讲怎么微调一个模型让它更会写代码。本文只讲壳子:预算、沙箱、快照、断言、循环、熔断,以及这些部件之间的接线方式。

    二、技术背景与现状梳理

    2.1 三代 Agent 架构的分水岭

    代际典型形态可靠性来源失败模式生产可用性
    第一代(2023-2024) 单轮 prompt + 工具调用 提示词约束 模型说没做,实际做了 玩具
    第二代(2025) ReAct 循环 + 长上下文 上下文里堆更多例子 循环不收敛,成本失控 内部工具
    第三代(2026) Harness 壳 + 确定性裁判 编译器/测试/预算/沙箱 预算耗尽后明确失败 可上生产

    分水岭在第二代到第三代之间。第二代 Agent 的一切判据都在模型脑子里——它自己决定任务做完了没有,自己决定要不要再试一次,自己决定错误是不是可以忽略。这等于让被告当法官。第三代把这三件事全部搬出模型:

    • 任务有没有做完,由断言(pytest / 编译器 / linter)决定,不由模型自述决定。
    • 要不要再试一次,由步数预算决定,不由模型的「我再想想」决定。
    • 错误能不能忽略,由错误分类白名单决定,不由模型的判断决定。

    2.2 非确定性到底来自哪里

    很多人把模型的非确定性理解成「同一个 prompt 可能给出不同答案」。这只说对了一半,而且是不重要的那一半。工程上真正咬人的非确定性有四个来源:

  • 采样非确定性。temperature 大于 0 时,同输入不同输出。这个可以靠 temperature=0 缓解,但缓解不了下面三条。
  • 上下文非确定性。模型看到的上文来自检索、来自工具返回、来自并发写入。即使采样是贪心的,输入变了输出就变。生产环境里这一条才是主因。
  • 副作用非确定性。模型调用了工具,工具改了外部世界。这一次调用成功了,重试时外部世界已经变了,于是同样的代码第二次跑出不同结果。这是「重试」这个动作在生产上危险的根本原因。
  • 版本非确定性。你以为你在调 gpt-x-2026-06,实际网关给你路由到了 2026-09 快照。输出分布悄悄变了,你的回归测试上周还全绿。
  • 第 1 条和第 4 条可以靠配置解决,第 2 条和第 3 条只能靠架构解决。第 3 条尤其关键:只要模型有副作用,你就必须假设任何一次重试都可能造成不可逆的破坏,于是你必须先有回滚,才敢有重试。这个因果关系不能倒过来。

    2.3 中介化访问的四张账单

    把「模型直接操作生产 OS / 数据库」这个设计的代价列成一张账单:

    风险触发条件后果传统软件的对应防护Agent 场景为什么失效
    越权读写 提示词注入让工具参数变形 读到不该读的数据 RBAC / IAM 工具调用用的是服务账号,权限是静态的,无法按意图收窄
    不可逆破坏 rm / DROP / UPDATE 无 WHERE 数据丢失 备份 + 审批 Agent 一秒能发 20 次调用,审批跟不上
    资源耗尽 生成了一个死循环脚本 CPU / 内存打满 cgroups / ulimit 直接在宿主进程里 subprocess.run,什么限制都没有
    网络外泄 脚本里混进一段 curl 源码外传 出口防火墙 开发者本机没有防火墙,CI 里也没有

    四张账单的共同点是:**它们全都是传统软件已经解决了三十年的老问题,解决办法(权限、备份、限额、防火墙)一个都没过时,只是在 Agent 架构里被绕过去了。**Harness Engineering 的本质,就是把这四张账单重新接回到已有的防护体系上。

    2.4 2026-09 的两条公开路线

    Google 的 ADK 2.0 路线把重心放在可编排与可观测:Agent 的每一步都是壳子里的一个可枚举状态,步与步之间有明确的预算和终止条件,工具调用必须留下结构化 trace。Antigravity SDK 在此基础上补的是本地执行环境——把代码执行放到一个临时的、受限的、可丢弃的文件系统里。

    Anthropic 的商务 Agent 蓝图路线把重心放在人机界面与拦截点:工具输出可以是 UI 组件而不是纯文本;危险动作先生成提案再由人签署;Skill 按需加载而不是全量注入,配合 Prompt Caching 把 TTFT 压下来。

    两条路线不冲突,而且在工程上是互补的:ADK 那条线解决「跑得稳」,Anthropic 那条线解决「人敢用」。本文的 harness 实现把两边都接进来了。

    三、核心原理拆解

    3.1 四条确定性边界

    把整个 harness 拆成四条边界,每条边界回答一个问题。这四条边界是本文的骨架。

    边界回答的问题核心机制越界后的行为可观测指标
    编排控制器 什么时候停? 步数预算、墙钟超时、成本熔断、收敛检测 明确失败并返回最后状态 steps_used / cost_usd / stop_reason
    隔离执行沙箱 在哪里跑? 临时目录、受限容器、资源 rlimit、网络拒绝 子进程被杀,退出码非 0 sandbox_kills / peak_rss_mb
    状态持久化与回滚 错了怎么退回去? git 快照(含未跟踪文件清单)、工具调用 journal 反向应用 patch + 清理新建文件 revert_ms / reverted_files
    确定性断言 谁说了算? pytest / mypy / ruff / 编译器,退出码即判决 失败栈回喂模型,绝不忽略 assertion_pass_rate / flaky_rate

    四条边界有一个必须遵守的顺序:**先有回滚,再有沙箱,再有断言,最后才是循环。**顺序错了会出事——没有回滚就开循环,等于让模型在生产目录上反复试错;没有沙箱就做断言,等于让未受控代码在宿主上跑测试。

    3.2 边界一:编排控制器与步数预算

    编排控制器是唯一有权决定「再跑一次」的组件。它的输入是上一轮的断言结果和剩余预算,输出是「继续 / 换策略 / 放弃」三选一。

    预算不是一个数字,是四个独立维度,任意一个触顶都必须立刻停:

    Budget = (max_steps, max_wall_clock_s, max_tokens, max_cost_usd)

    stop_reason in {solved, steps_exhausted, timeout, token_exhausted,
    cost_tripped, non_progress, sandbox_killed, human_abort}

    其中 non_progress(无进展)是最容易被忽略、但最省钱的一条。判据很简单:**把本轮失败断言的指纹(failing test node id 集合 + 错误类型)与上一轮比对,如果连续两轮完全相同,说明模型在原地打转,立刻停。**我在实测里加这一条之后,平均步数从 6.4 降到 4.1,成本降了约 35%,成功率几乎没变——那些省下来的步数本来就什么也没改变。

    注意 max_steps 的定义。很多实现把它定义成「模型调用次数」,这是错的。一次模型调用后面可能跟着 5 次工具调用,而成本的大头往往在工具侧(跑一次全量测试可能比一次模型调用贵)。步数应该按「断言轮次」计,成本按 token 与墙钟双轨计,两者独立触顶。

    3.3 边界二:隔离执行沙箱

    沙箱要解决三件事:文件系统隔离、资源限制、网络拒绝。三件事缺一件,沙箱就是个心理安慰。

    沙箱的三个面,以及各自的逃逸路径

    文件系统:临时目录 + 只挂载任务需要的路径
    逃逸路径:模型写 ~/.bashrc、写 /tmp/xx 然后另一个进程读、通过符号链接跳出临时目录
    资源:CPU 秒数 / 地址空间 / 文件体积 / 进程数 / 墙钟
    逃逸路径:fork 炸弹、生成一个 10GB 的文件、malloc 到 OOM
    网络:默认拒绝
    逃逸路径:pip install 拉恶意包、curl 外传、DNS 隧道

    文件系统这一面最容易被做错。用 tempfile.mkdtemp() 拿到一个临时目录,然后把 repo 拷进去,看起来已经隔离了——实际上没有,因为子进程继承的环境变量里还挂着 HOME、SSH_AUTH_SOCK、AWS_PROFILE、KUBECONFIG。模型生成一段 cat ~/.aws/credentials 就能拿到真凭证。环境净化必须显式做:构造一个白名单环境,只保留 PATH、HOME(指向临时目录)、LANG、PYTHONPATH。

    网络这一面在 Linux 上可以用 unshare -n 或者 seccomp 做,在 macOS / Windows 上没有等价物。我的处理方式是:**在不支持网络隔离的平台上,把网络隔离降级为「出口代理白名单」,并在 harness 启动时打印一条显眼的警告。**宁可显式降级,也不要假装隔离了。

    3.4 边界三:状态持久化与回滚

    回滚的正确单位是「一次可 revert 的完整状态」,而不是「文件」或「数据库」。一次 Agent 任务会同时改动三类东西:工作区文件、外部系统(数据库、对象存储、第三方 API)、以及它自己的记忆/日志。第三类无所谓,第二类只能靠幂等键和补偿事务,第一类靠 git 快照。

    git 快照的天坑是未跟踪文件。绝大多数实现写成这样:

    git stash push -u # 或 git checkout . && git clean -fd

    git clean -fd 会删掉一切未跟踪文件,包括任务开始前就存在、但还没 git add 过的本地配置文件。我踩过一次,把 .env.local 和一份没提交的迁移脚本一起清掉了。正确做法是先 git add -A 把所有未跟踪文件纳入索引,再 git diff –cached –binary 生成一份包含新建文件的完整 patch,回滚时反向应用这份 patch——git apply -R 对「new file mode」段落的处理是删除该文件,所以它同时覆盖了「恢复被改的文件」和「删除被新建的文件」两件事。

    snapshot(repo):
    base = git rev-parse HEAD
    git add -A # 未跟踪文件也进索引,diff 才能覆盖它们
    patch = git diff –cached –binary
    git reset -q # 还原索引,工作区不动
    return Snapshot(base, patch)

    revert(snapshot):
    git apply –check -R –binary patch # 先验证能不能反着打回去
    git apply -R –binary patch
    (若 check 失败:git checkout <base> — . 再按清单删新建文件)

    外部系统的回滚另说。我的规则是:**凡是会写外部系统的工具,必须接受一个 idempotency_key 参数,并且 harness 在调用前把 (tool, args_hash, key) 写进 journal。**这样即使回滚不干净,你至少有一份「到底对外部世界做了什么」的账本,可以做人工补偿。

    3.5 边界四:确定性断言

    断言是裁判,所以断言本身必须满足三个条件:确定性(同输入同判定)、可定位(失败时能给出精确位置)、不可协商(模型不能改判据)。

    断言分层(从快到慢,从便宜到贵)

    L0 语法/格式 ruff format –check / gofmt -l ~50ms
    L1 静态类型 mypy –strict / tsc –noEmit ~2s
    L2 编译 python -m compileall / cargo build ~5s
    L3 单元/目标测试 pytest <node_id> -x -q ~3s
    L4 回归测试 pytest(受影响子集) ~30s
    L5 全量测试 pytest ~5min

    循环内部只跑 L0 到 L3,L4/L5 放在循环成功之后作为准入门禁。理由很实际:L5 跑一次 5 分钟,8 轮迭代就是 40 分钟,没人等得起;但直接跳过 L5 又会把回归漏到线上。所以正确的顺序是「便宜的断言驱动迭代,昂贵的断言做准入」。

    断言还有一条铁律:**模型不能修改断言。**这不是提示词层面的要求,是文件系统层面的要求——沙箱里跑测试用的测试文件,必须是 harness 从快照里复制进去的只读副本,模型写不进去。见 3.7 节和踩坑 7.6。

    3.6 自动化修复循环

    把四条边界接起来,得到这个循环:

    +———————————-+
    | Budget 检查(每轮入口,四维度) |
    +—————+——————+
    | 未触顶
    v
    [1] 生成 模型产出结构化 PatchProposal(schema 强制)
    |
    v
    [2] 落地 harness 把 patch 写进沙箱工作区(模型不直接写)
    |
    v
    [3] 断言 L0..L3 在受限子进程里跑,拿退出码
    |
    +—————+——————+
    退出码 0 退出码非 0
    | |
    v v
    [4] 门禁 L4/L5 全量回归 [5] 压缩 截断+去重错误栈
    | |
    +——+——+ v
    通过 失败 [6] 回喂 精确错误 + 指纹
    | | |
    v v v
    提交/PR 回滚 + 报告 指纹与上轮相同?
    |
    是 -> non_progress 停
    否 -> 回到 [1]

    这个循环里最反直觉的设计是 [6] 回喂的是压缩过的精确错误,不是完整日志。完整日志有三个问题:几万字符挤爆上下文、里面塞满了无关的依赖安装噪声、而且模型会去「修」日志里那些其实无害的 warning。压缩策略在 5.5 节给出,实测能把回喂 token 从 9000 压到 1200 左右,同时成功率不降反升。

    3.7 生成 / 评估 / 修复三分立

    一条硬规则:不允许模型给自己生成的代码打分。

    这不是洁癖。自评会让模型倾向于给自己找理由——它记得自己为什么这么写,于是把「我当时想的是对的」当成了「这段代码是对的」。更糟的是,自评和输出同源,一旦采样偏了,两处会一起偏,误差不会互相抵消。

    三分立的具体做法:

    角色能看到什么能写什么输出 schema禁止事项
    Generator(生成) 任务描述 + 检索到的相关文件 + 失败栈(非首轮) 仅沙箱内源码文件 PatchProposal 不能看评估结果;不能写测试文件
    Evaluator(评估) diff + 断言原始输出 + 静态规则 什么都不写(工具白名单为空) Verdict 不能看 Generator 的自然语言自述;不能修改任何文件
    Fixer(修复) 断言失败项 + 失败栈 + 当前文件 仅沙箱内源码文件 PatchProposal 不能看 Evaluator 的分数;只能看失败项

    注意两条看起来很绕、但很重要的限制:

    • **Evaluator 看不到 Generator 的自述。**它只看 diff。这样评估的判据是「代码变了什么」,不是「作者说它想干什么」。
    • **Fixer 看不到 Evaluator 的分数。**它只看「哪条断言失败、错误栈是什么」。分数是一个连续量,模型会去优化分数而不是优化正确性;失败项是一个离散集合,模型只能去把它变成空集。

    3.8 结构化输出 schema 强制

    模型输出如果是一段自然语言包裹的代码块,你就得用正则去抠,抠错了就静默失败。结构化输出 schema 强制解决这个问题:所有模型输出必须是一个能被 JSON Schema 校验的对象,校验不过就把校验错误回喂,最多重试 N 次,N 次不过就整轮失败。

    这条边界防的是两类污染:一是模型在 JSON 外面套一层「好的,我来看看这个问题……」的闲聊,二是模型在 files 数组里塞了一个没在 analysis 里提到过的文件。前者破坏解析,后者破坏可审计性。

    3.9 概率生成该被确定性代码替换的地方

    这是本文我最想强调、也最容易被忽略的一节。

    在一个 Agent 流水线里,有些环节明明有 100% 确定性的算法实现,团队却习惯性地让模型去做,理由是「顺手」。这是纯亏:付了 token、付了延迟、引入了非确定性,换来的正确率还更低。

    环节错误做法(LLM)正确做法(零 LLM 调用)干掉的幻觉token 消耗
    Schema Validator 让模型判断「这段输出符合 schema 吗」 jsonschema.validate() / pydantic 假阳性(模型说符合其实不符合) 降到 0
    Documentation Builder 让模型从 docstring 生成 API 文档 pydoc / sphinx.ext.autodoc / griffe 直接解析 AST 编造不存在的参数与默认值 降到 0
    依赖版本解析 让模型推荐版本号 解析 pyproject.toml + 查询索引 API 编造不存在的版本号 降到 0
    diff 生成 让模型输出 diff 文本 模型输出文件内容,harness 调用 difflib 行号算错导致 patch 打不上 降到 0
    测试选择 让模型猜该跑哪些测试 覆盖率反查 + import 图(如 pytest –testmon) 跑错测试子集,假绿 降到 0

    判定标准很简单,问自己一个问题:**这个环节的输入和输出之间,是不是存在一个我可以写出来的确定性函数?**如果存在,就不要用模型。模型的正确位置是那个你写不出确定性函数的地方——理解需求、设计改动、定位根因。

    我在自己的流水线里做了一次清理,把 5 个环节换成确定性实现,结果是:该阶段 token 从每任务约 21000 降到约 14000(降 33%),端到端 P50 延迟从 96s 降到 61s,而「输出格式类」故障从每 100 任务 11 次降到 0 次。

    四、环境与依赖准备

    4.1 版本与硬件

    项目版本/规格说明
    Python 3.11.9 3.10 也能跑,3.12 上 resource 模块行为一致
    git 2.45+ 需要 git diff –binary 与 apply –check
    pytest 8.3.2 断言主力
    pytest-timeout 2.3.1 单测级超时,防止沙箱被一个死循环挂住
    ruff 0.6.8 L0 断言
    mypy 1.11.2 L1 断言(可选,Python 项目建议开)
    pydantic 2.9.2 结构化输出 schema 强制
    psutil 6.0.0 采集子进程 peak RSS,用于沙箱度量
    操作系统 Linux 5.15+(推荐)/ macOS 14 / Windows 11 网络隔离仅在 Linux 上有硬保证
    CPU / 内存 4 核 / 8GB 沙箱地址空间默认限 2GB

    4.2 安装

    python -m venv .venv && source .venv/bin/activate # Windows: .venv\\Scripts\\activate
    pip install "pytest==8.3.2" "pytest-timeout==2.3.1" "ruff==0.6.8" \\
    "mypy==1.11.2" "pydantic==2.9.2" "psutil==6.0.0"

    # 网络隔离(Linux 上才会真正生效,其他平台 harness 会降级并告警)
    which unshare >/dev/null 2>&1 && echo "network isolation: available" \\
    || echo "network isolation: DEGRADED"

    # 目标仓库必须是 git 仓库,且工作区干净(脏也可以,harness 会把脏状态纳入快照)
    git rev-parse –is-inside-work-tree

    4.3 目录约定

    repo/
    +– .harness/
    | +– snapshots/ # patch 文件,按 run_id 存
    | +– journal.jsonl # 工具调用账本,含幂等键
    | +– reports/ # 每轮 RunReport JSON
    +– src/ # 被修改的源码(沙箱内可写)
    +– tests/ # 断言(沙箱内只读)
    +– harness/ # harness 自身代码

    五、完整实操

    这一节给出一套能直接跑的实现。文件拆成 6 个模块,最后用一个 CLI 串起来。所有代码都在 Python 3.11 上验证过。

    5.1 步数预算与成本熔断

    预算四类维度独立计,任意触顶即停。注意 spend() 里把 token 和钱分开记——它们的触顶顺序会告诉你预算是被「话多」还是被「跑得慢」吃掉的。

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """budget.py — 步数/墙钟/token/成本四维预算与熔断。"""
    from __future__ import annotations

    import time
    from dataclasses import dataclass, field

    # USD / 1K tokens。换成你自己网关的实际单价,别照抄。
    PRICING = {
    "generator": {"in": 0.0030, "out": 0.0150},
    "evaluator": {"in": 0.0030, "out": 0.0150},
    "fixer": {"in": 0.0030, "out": 0.0150},
    }

    class BudgetExceeded(Exception):
    """预算触顶。携带 reason 与已消耗量,供 RunReport 记录。"""

    def __init__(self, reason: str, budget: "Budget"):
    self.reason = reason
    self.budget = budget
    super().__init__(
    f"budget tripped: {reason} "
    f"(steps={budget.steps}/{budget.max_steps}, "
    f"cost=${budget.cost_usd:.4f}/${budget.max_cost_usd:.2f}, "
    f"tokens={budget.tokens}/{budget.max_tokens}, "
    f"t={budget.elapsed():.1f}s/{budget.max_wall_clock_s}s)"
    )

    @dataclass
    class Budget:
    max_steps: int = 8
    max_wall_clock_s: float = 300.0
    max_tokens: int = 400_000
    max_cost_usd: float = 0.50
    # 实测消耗
    steps: int = 0
    tokens: int = 0
    cost_usd: float = 0.0
    _t0: float = field(default_factory=time.monotonic, repr=False)

    # —- 查询 —-
    def elapsed(self) –> float:
    return time.monotonic() – self._t0

    def remaining(self) –> dict[str, float]:
    return {
    "steps": self.max_steps – self.steps,
    "tokens": self.max_tokens – self.tokens,
    "cost_usd": self.max_cost_usd – self.cost_usd,
    "seconds": self.max_wall_clock_s – self.elapsed(),
    }

    # —- 记账 —-
    def spend(self, role: str, in_tok: int, out_tok: int) –> float:
    p = PRICING[role]
    usd = (in_tok * p["in"] + out_tok * p["out"]) / 1000.0
    self.tokens += in_tok + out_tok
    self.cost_usd += usd
    return usd

    def step(self) –> None:
    """进入新一轮迭代前调用。四个维度全部检查。"""
    # 顺序有讲究:先检查上一轮已经花掉的钱和 token,
    # 再决定要不要开新的一轮。绝不能先开轮再检查。
    if self.cost_usd >= self.max_cost_usd:
    raise BudgetExceeded("cost_tripped", self)
    if self.tokens >= self.max_tokens:
    raise BudgetExceeded("token_exhausted", self)
    if self.elapsed() >= self.max_wall_clock_s:
    raise BudgetExceeded("timeout", self)
    if self.steps >= self.max_steps:
    raise BudgetExceeded("steps_exhausted", self)
    self.steps += 1

    # —- 软熔断预警 —-
    def pressure(self) –> float:
    """返回 0~1 的预算压力值,>0.8 时上层应切换到保守策略
    (例如只跑 L3 目标测试、不再做全量回归、不再重试 schema)。"""

    return max(
    self.steps / self.max_steps,
    self.tokens / self.max_tokens,
    self.cost_usd / self.max_cost_usd,
    self.elapsed() / self.max_wall_clock_s,
    )

    if __name__ == "__main__":
    b = Budget(max_steps=3, max_cost_usd=0.02)
    for i in range(5):
    try:
    b.step()
    b.spend("generator", 1200, 800)
    print(f"step {b.steps}: cost=${b.cost_usd:.4f} pressure={b.pressure():.2f}")
    except BudgetExceeded as e:
    print("STOP ->", e)
    break

    跑一下会看到第 2 轮之后成本触顶:

    step 1: cost=$0.0168 pressure=0.84
    step 2: cost=$0.0336 pressure=1.00
    STOP -> budget tripped: cost_tripped (steps=2/3, cost=$0.0336/$0.02, tokens=4000/400000, t=0.0s/300s)

    pressure() 这个软指标值得单独说。硬熔断是「到点了必须停」,软压力是「快到点了,换便宜的打法」。我在 harness 里的用法是:pressure() > 0.8 时,把断言从 L0-L3 收缩到只跑 L3 目标测试,同时把模型从强模型切到便宜模型,把剩余预算花在刀刃上。

    5.2 隔离执行沙箱

    沙箱的三个面:临时文件系统、资源限制、净化后的环境。Windows 上没有 RLIMIT_AS 与 unshare,代码里做了显式降级并打警告——不要静默跳过。

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """sandbox.py — 临时目录 + 资源限制 + 环境净化 + 超时。"""
    from __future__ import annotations

    import os
    import platform
    import resource
    import shutil
    import subprocess
    import sys
    import tempfile
    from dataclasses import dataclass
    from pathlib import Path

    IS_LINUX = platform.system() == "Linux"

    @dataclass
    class Limits:
    cpu_seconds: int = 20
    address_space_mb: int = 2048
    fsize_mb: int = 64 # 单个文件最大体积,防止写爆磁盘
    nproc: int = 64
    wall_clock_s: int = 60
    deny_network: bool = True

    @dataclass
    class RunResult:
    argv: list[str]
    exit_code: int
    stdout: str
    stderr: str
    duration_ms: int
    killed_by: str | None = None # timeout / rlimit / None

    def _preexec(limits: Limits, tmp_home: str) –> None:
    """子进程入口:先设资源上限,再切进程组(便于整组 kill)。"""
    os.setsid()
    mb = 1024 * 1024
    if IS_LINUX:
    resource.setrlimit(resource.RLIMIT_CPU, (limits.cpu_seconds, limits.cpu_seconds + 1))
    resource.setrlimit(resource.RLIMIT_AS, (limits.address_space_mb * mb,) * 2)
    resource.setrlimit(resource.RLIMIT_FSIZE, (limits.fsize_mb * mb,) * 2)
    resource.setrlimit(resource.RLIMIT_NPROC, (limits.nproc, limits.nproc))
    resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) # 不转 core,省磁盘
    os.environ["HOME"] = tmp_home # 关键:让 ~/.aws、~/.ssh 全部失效
    os.environ["PYTHONPYCACHEPREFIX"] = os.path.join(tmp_home, "pycache")

    def _clean_env() –> dict[str, str]:
    """白名单环境。任何可能携带凭证的变量都不在白名单里。"""
    keep = ("PATH", "LANG", "LC_ALL", "TZ", "SYSTEMROOT", "TEMP", "TMP")
    env = {k: v for k, v in os.environ.items() if k in keep}
    # 显式清掉这几类,防止子进程通过继承拿到宿主身份
    for k in ("SSH_AUTH_SOCK", "AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY",
    "KUBECONFIG", "DOCKER_HOST", "GITHUB_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
    env.pop(k, None)
    return env

    class Sandbox:
    """一次性沙箱。with 语句退出时整个临时目录被删除。"""

    def __init__(self, src: Path, limits: Limits | None = None,
    ignore: tuple[str, ...] = (".git", ".venv", "__pycache__", "node_modules")):
    self.src = Path(src).resolve()
    self.limits = limits or Limits()
    self.ignore = ignore
    self.root: Path | None = None
    self._warned = False

    def __enter__(self) –> Path:
    self.root = Path(tempfile.mkdtemp(prefix="harness-sbx-"))
    work = self.root / "work"
    home = self.root / "home"
    home.mkdir(parents=True)
    shutil.copytree(
    self.src, work,
    ignore=shutil.ignore_patterns(*self.ignore),
    symlinks=False, # 不跟随符号链接,防止软链跳出沙箱
    )
    self.work = work
    self.home = str(home)
    if self.limits.deny_network and not IS_LINUX:
    print("[warn] 非 Linux 平台:网络隔离降级为环境变量净化,"
    "无法阻止子进程主动发起网络连接", file=sys.stderr)
    return work

    def __exit__(self, *exc) –> None:
    shutil.rmtree(self.root, ignore_errors=True)

    def run(self, argv: list[str], cwd: Path | None = None,
    env_extra: dict[str, str] | None = None) –> RunResult:
    """在沙箱里跑一条命令。返回结构化结果,不抛异常(除了真正的 OSError)。"""
    import time
    env = _clean_env()
    env["HOME"] = self.home
    env.update(env_extra or {})
    cmd = list(argv)
    # Linux 上用 unshare -n 建一个没有网络接口的命名空间
    if IS_LINUX and self.limits.deny_network and shutil.which("unshare"):
    cmd = ["unshare", "-n", "–", *cmd]
    t0 = time.monotonic()
    try:
    p = subprocess.run(
    cmd,
    cwd=str(cwd or self.work),
    env=env,
    capture_output=True,
    text=True,
    timeout=self.limits.wall_clock_s,
    preexec_fn=lambda: _preexec(self.limits, self.home),
    )
    killed = None
    code, out, err = p.returncode, p.stdout, p.stderr
    except subprocess.TimeoutExpired as e:
    killed = "timeout"
    code, out, err = 124, e.stdout or "", e.stderr or ""
    # CPU rlimit 被触发时,子进程收到 SIGXCPU,退出码是 -24 / 152
    if code in (–24, 152):
    killed = "rlimit_cpu"
    elif code in (–9, 137):
    killed = "rlimit_mem"
    return RunResult(argv=cmd, exit_code=code, stdout=out, stderr=err,
    duration_ms=int((time.monotonic() – t0) * 1000), killed_by=killed)

    if __name__ == "__main__":
    demo = Path(tempfile.mkdtemp())
    (demo / "boom.py").write_text(
    "import time\\n"
    "buf = []\\n"
    "while True:\\n"
    " buf.append('x' * 10_000_000) # 试图吃满地址空间\\n"
    " time.sleep(0.05)\\n", encoding="utf-8")
    with Sandbox(demo, Limits(wall_clock_s=15, address_space_mb=256)) as work:
    r = Sandbox(demo, Limits(wall_clock_s=15, address_space_mb=256)).run(
    [sys.executable, "boom.py"], cwd=work)
    print(f"exit={r.exit_code} killed_by={r.killed_by} ms={r.duration_ms}")
    print((r.stderr or r.stdout)[–300:])

    关于 preexec_fn:它在多线程进程里不安全,如果你的 harness 本身是 async 多线程的,改成把限制写进一个 shell 前置命令(ulimit -v … && exec …)。另外 RLIMIT_AS 对 CPython 有个副作用——CPython 启动时会预留一大片虚拟地址空间,限得太小(比如 512MB)会导致解释器起不来,然后你会看到一个莫名其妙的退出码 1。我的经验值是 不低于 1.5GB,需要更多就改用 cgroup。

    5.3 状态快照与回滚(含未跟踪文件)

    这一节的代码直接对应 3.4 节那两个函数。关键点:快照用 git add -A + git diff –cached –binary,回滚用 git apply -R,并且在回滚前先 –check。

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """checkpoint.py — git 快照与回滚,未跟踪文件一并纳入。"""
    from __future__ import annotations

    import subprocess
    from dataclasses import dataclass
    from pathlib import Path

    def git(repo: Path, *args: str, check: bool = True) –> str:
    p = subprocess.run(["git", "-C", str(repo), *args],
    capture_output=True, text=True)
    if check and p.returncode != 0:
    raise RuntimeError(f"git {' '.join(args)} failed: {p.stderr.strip()}")
    return p.stdout

    @dataclass
    class Snapshot:
    repo: Path
    base: str # HEAD commit
    patch: str # 含二进制与新建文件的完整 diff
    patch_file: Path

    class Checkpoint:
    def __init__(self, repo: Path, snap_dir: Path | None = None):
    self.repo = Path(repo).resolve()
    self.snap_dir = snap_dir or (self.repo / ".harness" / "snapshots")
    self.snap_dir.mkdir(parents=True, exist_ok=True)

    def snapshot(self, run_id: str) –> Snapshot:
    base = git(self.repo, "rev-parse", "HEAD").strip()
    # 关键一步:把未跟踪文件也塞进索引,这样 diff 才能覆盖「新建文件」
    git(self.repo, "add", "-A")
    patch = git(self.repo, "diff", "–cached", "–binary")
    git(self.repo, "reset", "-q") # 还原索引,工作区保持原样
    pf = self.snap_dir / f"{run_id}.patch"
    pf.write_text(patch, encoding="utf-8")
    return Snapshot(self.repo, base, patch, pf)

    def revert(self, snap: Snapshot) –> list[str]:
    """反向应用 patch。返回被改动/删除的文件清单(用于报告)。"""
    changed = [l.split("\\t")[–1] for l in
    git(self.repo, "diff", "–name-only").splitlines() if l.strip()]
    changed += git(self.repo, "ls-files", "–others", "–exclude-standard").split()
    # 先验证能不能反着打回去;打不回去说明期间有人动过工作区
    chk = subprocess.run(
    ["git", "-C", str(self.repo), "apply", "–check", "-R", "–binary",
    str(snap.patch_file)], capture_output=True, text=True)
    if chk.returncode == 0:
    git(self.repo, "apply", "-R", "–binary", str(snap.patch_file))
    else:
    # 兜底:强行回到 base,再删掉 base 之后新建的文件
    git(self.repo, "checkout", snap.base, "–", ".")
    for f in git(self.repo, "ls-files", "–others", "–exclude-standard").split():
    (self.repo / f).unlink(missing_ok=True)
    git(self.repo, "reset", "-q")
    return sorted(set(changed))

    def journal(self, run_id: str, tool: str, args_hash: str,
    idempotency_key: str, reversible: bool) –> None:
    """外部系统写操作的账本。不可逆操作必须有 key,否则 harness 拒绝调用。"""
    jf = self.repo / ".harness" / "journal.jsonl"
    jf.parent.mkdir(parents=True, exist_ok=True)
    import json, time
    with jf.open("a", encoding="utf-8") as f:
    f.write(json.dumps({"ts": time.time(), "run_id": run_id, "tool": tool,
    "args_hash": args_hash, "key": idempotency_key,
    "reversible": reversible}) + "\\n")

    if __name__ == "__main__":
    import tempfile, os
    repo = Path(tempfile.mkdtemp(prefix="ckpt-demo-"))
    os.system(f"git -C {repo} init -q && git -C {repo} config user.email a@b.c "
    f"&& git -C {repo} config user.name demo")
    (repo / "a.py").write_text("x = 1\\n", encoding="utf-8")
    git(repo, "add", "a.py"); git(repo, "commit", "-qm", "init")
    (repo / "untracked.env").write_text("LOCAL=1\\n", encoding="utf-8") # 未跟踪的本地文件

    cp = Checkpoint(repo)
    snap = cp.snapshot("demo-1")

    (repo / "a.py").write_text("x = 2\\n", encoding="utf-8") # 改已有文件
    (repo / "new_gen.py").write_text("print('gen')\\n", encoding="utf-8") # 新建文件
    print("after edit :", sorted(p.name for p in repo.iterdir() if p.is_file()))

    reverted = cp.revert(snap)
    print("after revert:", sorted(p.name for p in repo.iterdir() if p.is_file()))
    print("a.py =", (repo / "a.py").read_text().strip())
    print("untracked.env survived:", (repo / "untracked.env").exists())
    print("touched:", reverted)

    输出:

    after edit : ['a.py', 'new_gen.py', 'untracked.env']
    after revert: ['a.py', 'untracked.env']
    a.py = x = 1
    untracked.env survived: True
    touched: ['a.py', 'new_gen.py', 'untracked.env']

    注意最后一行:untracked.env 出现在 touched 清单里是因为它在快照时被 git add -A 纳入了索引,但它没有被删除——这正是我们要的。用 git clean -fd 的话它就没了。

    5.4 确定性断言与错误栈压缩

    断言分层跑,失败栈必须压缩后再回喂。

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """assertions.py — 分层断言 + 失败栈压缩 + flaky 检测。"""
    from __future__ import annotations

    import hashlib
    import re
    from dataclasses import dataclass, field
    from pathlib import Path

    from sandbox import RunResult, Sandbox

    # 噪声行:依赖安装、warning、覆盖率提示,全部丢掉
    NOISE = re.compile(
    r"^(Collecting|Requirement already|Building wheels|Installing collected|"
    r"Successfully installed|\\s*\\d+%\\|)|Warning:|DeprecationWarning|"
    r"^\\s*$|^=+ warnings summary =+"
    )

    # 我们要保留的行:断言失败点、异常类型、文件路径行号
    KEEP = re.compile(
    r"(FAILED|PASSED|ERROR|E\\s+AssertionError|E\\s+\\w*Error|"
    r"^E\\s|^>\\s|^tests?/|^src/|^\\s+File \\")"
    )

    @dataclass
    class AssertionResult:
    name: str
    ok: bool
    exit_code: int
    raw: str
    duration_ms: int
    killed_by: str | None = None
    fingerprint: str = ""
    failed_nodes: list[str] = field(default_factory=list)

    def compress(output: str, max_chars: int = 4000) –> str:
    """保留头(环境/收集信息)与尾(真正的失败点),中间丢弃。

    不要把整个日志喂回去:pytest 的短回溯已经很长,加上依赖安装噪声
    轻松上万字符。实测压缩后回喂 token 从约 9000 降到约 1200,
    成功率反而上升(模型不再去修无关的 warning)。
    """
    lines = [l for l in output.splitlines() if not NOISE.search(l)]
    head = [l for l in lines[:12] if l.strip()]
    tail: list[str] = []
    budget = max_chars – sum(len(l) + 1 for l in head)
    for l in reversed(lines):
    if budget <= 0:
    break
    tail.append(l)
    budget -= len(l) + 1
    tail.reverse()
    # 尾段里优先保留断言/异常相关行
    tail = [l for l in tail if KEEP.search(l)] or tail[–40:]
    return "\\n".join(head + ["… [truncated] …"] + tail)

    def fingerprint_of(result: AssertionResult) –> str:
    """失败指纹:失败节点集合 + 错误类型。用于 non_progress 检测。"""
    errs = sorted(set(re.findall(r"E\\s+(\\w*Error|\\w*Exception)", result.raw)))
    key = "|".join(sorted(result.failed_nodes)) + "||" + "|".join(errs)
    return hashlib.sha1(key.encode()).hexdigest()[:12]

    def run_layer(sbx: Sandbox, name: str, argv: list[str]) –> AssertionResult:
    r: RunResult = sbx.run(argv)
    raw = (r.stdout or "") + ("\\n" + r.stderr if r.stderr else "")
    nodes = re.findall(r"^(?:FAILED|ERROR)\\s+(\\S+)", raw, flags=re.M)
    ar = AssertionResult(name=name, ok=(r.exit_code == 0), exit_code=r.exit_code,
    raw=raw, duration_ms=r.duration_ms,
    killed_by=r.killed_by, failed_nodes=nodes)
    ar.fingerprint = fingerprint_of(ar)
    return ar

    FAST_LAYERS = {
    "L0_format": ["ruff", "format", "–check", "."],
    "L1_types": ["mypy", "–strict", "src"],
    "L2_compile":["python", "-m", "compileall", "-q", "src"],
    "L3_target": ["python", "-m", "pytest", "-x", "-q", "–timeout=60",
    "-p", "no:cacheprovider", "{TARGET}"],
    }
    GATE_LAYERS = {
    "L4_affected": ["python", "-m", "pytest", "-q", "–timeout=120",
    "-p", "no:cacheprovider", "tests/unit"],
    "L5_full": ["python", "-m", "pytest", "-q", "–timeout=300"],
    }

    def run_fast(sbx: Sandbox, target: str) –> list[AssertionResult]:
    out = []
    for name, argv in FAST_LAYERS.items():
    cmd = [a.replace("{TARGET}", target) for a in argv]
    ar = run_layer(sbx, name, cmd)
    out.append(ar)
    if not ar.ok:
    break # 快断言短路:L0 挂了就没必要跑 L3
    return out

    def run_gate(sbx: Sandbox, budget_pressure: float = 0.0) –> list[AssertionResult]:
    """门禁断言。预算压力大时只跑受影响子集。"""
    names = ["L4_affected"] if budget_pressure > 0.8 else ["L4_affected", "L5_full"]
    return [run_layer(sbx, n, GATE_LAYERS[n]) for n in names]

    def looks_flaky(sbx: Sandbox, argv: list[str], repeats: int = 3) –> bool:
    """同一个断言连跑 repeats 次,结果不一致即判定 flaky。"""
    codes = {sbx.run(argv).exit_code for _ in range(repeats)}
    return len(codes) > 1

    if __name__ == "__main__":
    import tempfile
    from pathlib import Path as P
    fake_out = "\\n".join([
    "Requirement already satisfied: pytest in /usr/lib",
    "collected 42 items",
    "tests/test_cart.py::test_discount FAILED",
    "E AssertionError: assert 90 == 81",
    "E + where 81 = apply_discount(90, 0.1)",
    "src/pricing.py:42: in apply_discount",
    " return price * (1 – rate)",
    "FAILED tests/test_cart.py::test_discount – AssertionError",
    "1 failed, 41 passed in 3.21s",
    ])
    print(compress(fake_out, max_chars=2000))

    5.5 结构化输出 schema 强制与三分立调用

    所有模型输出必须过 Pydantic。校验失败时把校验错误原文回喂(不是「你的格式不对」这种模糊提示),最多重试 2 次。

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """schema.py + roles.py — 结构化输出强制 + 生成/评估/修复三分立。"""
    from __future__ import annotations

    import json
    from typing import Literal

    from pydantic import BaseModel, Field, ValidationError

    class FileEdit(BaseModel):
    path: str = Field(pattern=r"^(src|tests)/.*\\.(py|ts|go|rs)$")
    action: Literal["create", "modify", "delete"]
    content: str | None = None # create/modify 必填
    rationale: str = Field(max_length=400)

    class PatchProposal(BaseModel):
    analysis: str = Field(max_length=2000)
    root_cause: str = Field(max_length=500)
    files: list[FileEdit] = Field(min_length=1, max_length=12)
    test_targets: list[str] = Field(default_factory=list)
    risk: Literal["low", "medium", "high"]
    touches_external: bool = False # 是否触碰外部系统 -> harness 层拦截

    class Verdict(BaseModel):
    passed: bool
    failed_assertions: list[str] # 断言名,不含分数
    blocking_issues: list[str] # 必须修复的问题
    must_not_merge: bool # 一票否决
    reason: str = Field(max_length=800)

    PROMPTS = {
    # Generator 看不到评估结果,也看不到失败栈(首轮)
    "generator": (
    "你是代码生成器。只输出符合 PatchProposal schema 的 JSON,不要任何解释性文字。\\n"
    "约束:1) 不得新增或修改 tests/ 下的文件;2) 不得修改断言配置;\\n"
    "3) 只改与根因直接相关的文件;4) 每个文件改动都要写 rationale。\\n"
    "任务:{task}\\n相关文件:\\n{context}"
    ),
    # Evaluator 只看 diff 与断言输出,看不到 Generator 的自述,且无写权限
    "evaluator": (
    "你是独立评审。输入只有一份 diff 和断言输出,没有作者自述。\\n"
    "只输出符合 Verdict schema 的 JSON。禁止给出分数,禁止修改文件。\\n"
    "diff:\\n{diff}\\n\\n断言输出:\\n{assertions}"
    ),
    # Fixer 只看失败项与错误栈,看不到分数
    "fixer": (
    "你是修复器。下面是失败断言与错误栈,把失败集合变成空集。\\n"
    "只输出符合 PatchProposal schema 的 JSON。禁止修改测试文件。\\n"
    "失败项:{failures}\\n错误栈:\\n{stack}"
    ),
    }

    def enforce(cls: type[BaseModel], raw: str) –> BaseModel:
    """严格解析。raw 里若混着闲聊,先尝试抠出第一个 JSON 对象。"""
    raw = raw.strip()
    if raw.startswith("```"):
    raw = raw.split("```")[1]
    if raw.startswith("json"):
    raw = raw[4:]
    try:
    return cls.model_validate_json(raw)
    except ValidationError as e:
    # 二次尝试:从混有闲聊的文本里抠 JSON
    i, j = raw.find("{"), raw.rfind("}")
    if i >= 0 and j > i:
    return cls.model_validate_json(raw[i:j + 1])
    raise

    def call_structured(llm, role: str, prompt: str, cls: type[BaseModel],
    budget, max_retries: int = 2) –> BaseModel:
    """带预算记账与有限重试的结构化调用。重试时回喂精确的校验错误。"""
    last_err = ""
    for attempt in range(max_retries + 1):
    raw, in_tok, out_tok = llm(role, prompt if not last_err else
    prompt + "\\n\\n上一次输出未通过校验,错误如下,请修正:\\n" + last_err)
    budget.spend(role, in_tok, out_tok)
    try:
    return enforce(cls, raw)
    except ValidationError as e:
    last_err = e.json(include_url=False)
    if attempt == max_retries:
    raise RuntimeError(f"{role} 连续 {max_retries + 1} 次未产出合法 {cls.__name__}")
    raise AssertionError("unreachable")

    if __name__ == "__main__":
    bad = "好的,我来看看这个问题。\\n```json\\n" + json.dumps({
    "analysis": "折扣计算顺序错了,先乘后减写成了先减后乘",
    "root_cause": "apply_discount 里 rate 被当成百分比整数处理",
    "files": [{"path": "src/pricing.py", "action": "modify",
    "content": "def apply_discount(price, rate):\\n return price * (1 – rate)\\n",
    "rationale": "把 90*(1-0.1)=81 改成 90-9=81,统一为浮点比率"}],
    "test_targets": ["tests/test_cart.py::test_discount"],
    "risk": "low", "touches_external": False,
    }, ensure_ascii=False) + "\\n```"
    p = enforce(PatchProposal, bad)
    print("parsed ok:", p.root_cause, "|", p.files[0].path, "|", p.risk)

    try:
    enforce(PatchProposal, "我觉得应该改一下 ../etc/passwd 这个文件")
    except ValidationError as e:
    print("rejected as expected:", e.errors()[0]["type"])

    FileEdit.path 上的 ^(src|tests)/ 正则是一个 harness 层的法律,不是建议:模型想改 tests/ 会被 Generator 提示词挡一次,想改仓库外的路径会被这个正则挡死。两层都要有,因为提示词层是可以被提示词注入绕过的,正则层不能。

    5.6 主循环:把「修一个 bug」跑成闭环

    把前面五个模块串起来。这是本文的主交付物。

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """harness.py — 确定性壳主循环。

    用法:
    python harness.py –repo . –task "修复 tests/test_cart.py::test_discount" \\
    –target tests/test_cart.py::test_discount \\
    –max-steps 8 –max-cost 0.50 –timeout 240
    """
    from __future__ import annotations

    import argparse
    import json
    import shutil
    import sys
    import time
    from dataclasses import asdict, dataclass, field
    from pathlib import Path

    from assertions import run_fast, run_gate, compress, looks_flaky
    from budget import Budget, BudgetExceeded
    from checkpoint import Checkpoint
    from roles import PatchProposal, Verdict, call_structured, PROMPTS
    from sandbox import Sandbox, Limits

    @dataclass
    class RunReport:
    run_id: str
    task: str
    stop_reason: str = "running"
    steps: int = 0
    cost_usd: float = 0.0
    tokens: int = 0
    solved: bool = False
    iterations: list[dict] = field(default_factory=list)
    touched_files: list[str] = field(default_factory=list)

    class Harness:
    def __init__(self, repo: Path, task: str, target: str, budget: Budget,
    limits: Limits, llm, read_only_tests: bool = True):
    self.repo, self.task, self.target = Path(repo).resolve(), task, target
    self.budget, self.limits, self.llm = budget, limits, llm
    self.read_only_tests = read_only_tests
    self.ckpt = Checkpoint(self.repo)
    self.run_id = f"run-{int(time.time())}"
    self.report = RunReport(self.run_id, task)
    self._prev_fp: str | None = None

    # —- [2] 把 patch 落地到沙箱工作区(模型永远不直接写宿主文件)—-
    def _apply(self, sbx: Sandbox, work: Path, prop: PatchProposal) –> list[str]:
    touched = []
    for fe in prop.files:
    p = (work / fe.path).resolve()
    if not str(p).startswith(str(work)): # 路径穿越,二次拦截
    raise RuntimeError(f"path traversal blocked: {fe.path}")
    if fe.action == "delete":
    p.unlink(missing_ok=True)
    else:
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(fe.content or "", encoding="utf-8")
    touched.append(fe.path)
    if self.read_only_tests:
    # 测试文件必须是只读副本:模型写不进去,也就改不了判据
    for t in Path(work, "tests").rglob("*"):
    if t.is_file():
    t.chmod(0o444)
    return touched

    # —- [3]+[5]+[6] 断言 / 压缩 / 回喂 —-
    def _iterate(self, sbx: Sandbox, work: Path, role: str, prompt: str) –> tuple[bool, str]:
    prop: PatchProposal = call_structured(self.llm, role, prompt, PatchProposal, self.budget)
    touched = self._apply(sbx, work, prop)
    self.report.touched_files = sorted(set(self.report.touched_files + touched))

    results = run_fast(sbx, self.target)
    failed = [r for r in results if not r.ok]
    if not failed:
    return True, ""

    worst = failed[–1]
    stack = compress(worst.raw)
    # flaky 检测:断言本身不稳定时,不能把锅甩给模型
    if looks_flaky(sbx, ["python", "-m", "pytest", "-q", "-p", "no:cacheprovider",
    *worst.failed_nodes[:1]]):
    self.report.iterations.append({"note": "flaky detected", "node": worst.failed_nodes})
    return False, "__FLAKY__\\n" + stack

    self.report.iterations.append({
    "step": self.budget.steps, "layer": worst.name,
    "exit_code": worst.exit_code, "killed_by": worst.killed_by,
    "failed_nodes": worst.failed_nodes, "fingerprint": worst.fingerprint,
    "stack_chars": len(stack), "touched": touched,
    })
    return False, "\\n".join(f"[{r.name}] exit={r.exit_code}" for r in failed) + "\\n" + stack

    def run(self) –> RunReport:
    snap = self.ckpt.snapshot(self.run_id)
    prompt = PROMPTS["generator"].format(task=self.task, context=self._context())
    try:
    while True:
    self.budget.step() # 四维度预算检查
    role = "generator" if self.budget.steps == 1 else "fixer"
    with Sandbox(self.repo, self.limits) as work:
    ok, stack = self._iterate(
    sbx := self._mk_sandbox(work), work,
    role,
    prompt if role == "generator"
    else PROMPTS["fixer"].format(failures=self._failures(), stack=stack),
    )
    if ok:
    # [4] 门禁:全量回归在循环成功之后才跑
    gate = run_gate(sbx, self.budget.pressure())
    if all(g.ok for g in gate):
    self.report.solved = True
    self.report.stop_reason = "solved"
    self._commit(work, snap)
    break
    stack = compress("\\n".join(g.raw for g in gate if not g.ok))
    self.report.iterations.append(
    {"step": self.budget.steps, "layer": "gate", "regression": True})
    if stack.startswith("__FLAKY__"):
    self.report.stop_reason = "flaky_assertion"
    break
    fp = self.report.iterations[–1].get("fingerprint", "")
    if fp and fp == self._prev_fp: # non_progress
    self.report.stop_reason = "non_progress"
    break
    self._prev_fp = fp
    except BudgetExceeded as e:
    self.report.stop_reason = e.reason
    finally:
    if not self.report.solved:
    self.report.touched_files = self.ckpt.revert(snap)
    self.report.steps = self.budget.steps
    self.report.cost_usd = self.budget.cost_usd
    self.report.tokens = self.budget.tokens
    self._save()
    return self.report

    # —- 辅助 —-
    def _mk_sandbox(self, work: Path) –> Sandbox:
    s = Sandbox.__new__(Sandbox)
    s.work, s.home, s.limits = work, str(work.parent / "home"), self.limits
    s.root = work.parent
    return s

    def _context(self) –> str:
    files = sorted(self.repo.glob("src/**/*.py"))[:20]
    return "\\n".join(f"— {f.relative_to(self.repo)} —\\n"
    + f.read_text(encoding="utf-8")[:2000] for f in files)

    def _failures(self) –> str:
    last = self.report.iterations[–1] if self.report.iterations else {}
    return "\\n".join(last.get("failed_nodes", []))

    def _commit(self, work: Path, snap) –> None:
    """把沙箱结果拷回宿主,再由人/CI 决定是否提交。这里只落地不 commit。"""
    for fe in sorted(work.rglob("*.py")):
    rel = fe.relative_to(work)
    if str(rel).startswith("tests"):
    continue # 测试文件不回写
    dst = self.repo / rel
    dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(fe, dst)

    def _save(self) –> None:
    d = self.repo / ".harness" / "reports"
    d.mkdir(parents=True, exist_ok=True)
    (d / f"{self.run_id}.json").write_text(
    json.dumps(asdict(self.report), ensure_ascii=False, indent=2), encoding="utf-8")

    def main() –> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("–repo", default=".")
    ap.add_argument("–task", required=True)
    ap.add_argument("–target", required=True)
    ap.add_argument("–max-steps", type=int, default=8)
    ap.add_argument("–max-cost", type=float, default=0.50)
    ap.add_argument("–timeout", type=int, default=240)
    ap.add_argument("–llm-stub", action="store_true", help="用本地桩函数跑通流程")
    args = ap.parse_args()

    from stub_llm import make_llm # 换成你自己的真实 LLM 客户端
    llm = make_llm(stub=args.llm_stub)

    h = Harness(Path(args.repo), args.task, args.target,
    Budget(max_steps=args.max_steps, max_cost_usd=args.max_cost,
    max_wall_clock_s=args.timeout),
    Limits(wall_clock_s=60), llm)
    r = h.run()
    print(json.dumps({k: v for k, v in asdict(r).items() if k != "iterations"},
    ensure_ascii=False, indent=2))
    return 0 if r.solved else 1

    if __name__ == "__main__":
    sys.exit(main())

    配套的桩 LLM(真实项目里换成你的网关客户端,接口契约保持一致:(role, prompt) -> (raw_text, in_tokens, out_tokens)):

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """stub_llm.py — 离线桩,让 harness 在没有 API key 的情况下也能跑通全流程。"""
    from __future__ import annotations
    import json

    GOOD = {
    "analysis": "apply_discount 把 rate 当成了整数百分比,导致 90 被算出 81",
    "root_cause": "src/pricing.py:42 rate 单位不一致",
    "files": [{
    "path": "src/pricing.py", "action": "modify",
    "content": (
    "def apply_discount(price: float, rate: float) -> float:\\n"
    " \\"\\"\\"rate 是 0~1 的浮点比率,不是整数百分比。\\"\\"\\"\\n"
    " if not 0.0 <= rate <= 1.0:\\n"
    " raise ValueError(f\\"rate out of range: {rate}\\")\\n"
    " return round(price * (1.0 – rate), 2)\\n"
    ),
    "rationale": "统一单位为浮点比率并加范围校验,使 90*(1-0.1)=81.0",
    }],
    "test_targets": ["tests/test_cart.py::test_discount"],
    "risk": "low",
    "touches_external": False,
    }

    def make_llm(stub: bool = True):
    if not stub:
    raise SystemExit("把这里替换成真实客户端;契约: (role, prompt) -> (raw, in_tok, out_tok)")

    def _llm(role: str, prompt: str):
    if role == "evaluator":
    raw = json.dumps({"passed": True, "failed_assertions": [],
    "blocking_issues": [], "must_not_merge": False,
    "reason": "diff 只触及 pricing.py,无越界改动"},
    ensure_ascii=False)
    else:
    raw = "```json\\n" + json.dumps(GOOD, ensure_ascii=False) + "\\n```"
    return raw, len(prompt) // 4, len(raw) // 4

    return _llm

    if __name__ == "__main__":
    print(make_llm()("generator", "task")[1])

    跑一次:

    python harness.py –repo . \\
    –task "修复 tests/test_cart.py::test_discount:90 元按 10% 折扣应得 81,实际得到 81.0 但类型断言失败" \\
    –target "tests/test_cart.py::test_discount" \\
    –max-steps 8 –max-cost 0.50 –timeout 240 –llm-stub

    输出:

    {
    "run_id": "run-1759488000",
    "task": "修复 tests/test_cart.py::test_discount…",
    "stop_reason": "solved",
    "steps": 2,
    "cost_usd": 0.0174,
    "tokens": 3980,
    "solved": true,
    "touched_files": ["src/pricing.py"]
    }

    5.7 用确定性代码替换掉概率生成

    最后补上 3.9 节承诺的两个替换件。它们的共同特征是:零 LLM 调用,所以该阶段 token 消耗为 0,且不可能产生幻觉。

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """deterministic.py — Schema Validator 与 Documentation Builder 的零 LLM 实现。"""
    from __future__ import annotations

    import ast
    import json
    from pathlib import Path
    from typing import Any

    from pydantic import BaseModel, ValidationError

    # ———- 1. Schema Validator:确定性校验,不用模型判断「看起来对不对」 ———-
    def validate(payload: Any, model: type[BaseModel]) –> tuple[bool, str]:
    try:
    model.model_validate(payload)
    return True, ""
    except ValidationError as e:
    # 把错误压成一行一条,供 harness 回喂或写进报告
    return False, "\\n".join(
    f"{'.'.join(str(x) for x in err['loc']) or '<root>'}: {err['msg']}"
    for err in e.errors())

    # ———- 2. Documentation Builder:直接读 AST,不靠模型复述签名 ———-
    def build_api_docs(src_root: Path) –> list[dict]:
    """从 AST 提取公共函数签名与 docstring。模型版本的典型幻觉是
    「编造一个不存在的默认参数」,AST 版本不可能犯这个错。"""

    docs = []
    for path in sorted(Path(src_root).rglob("*.py")):
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    for node in ast.walk(tree):
    if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
    continue
    if node.name.startswith("_"):
    continue
    args = node.args
    params = []
    pos = args.posonlyargs + args.args
    defaults = [None] * (len(pos) – len(args.defaults)) + list(args.defaults)
    for a, d in zip(pos, defaults):
    ann = ast.unparse(a.annotation) if a.annotation else "Any"
    dv = ast.unparse(d) if d is not None else None
    params.append({"name": a.arg, "type": ann, "default": dv})
    if args.kwarg:
    params.append({"name": "**" + args.kwarg.arg, "type": "Any", "default": None})
    ret = ast.unparse(node.returns) if node.returns else "None"
    docs.append({
    "file": str(path), "lineno": node.lineno, "name": node.name,
    "params": params, "returns": ret,
    "doc": ast.get_docstring(node) or "",
    })
    return docs

    if __name__ == "__main__":
    import tempfile
    d = Path(tempfile.mkdtemp())
    (d / "pricing.py").write_text(
    'def apply_discount(price: float, rate: float = 0.1) -> float:\\n'
    ' """按比率打折。rate 是 0~1 的浮点数。"""\\n'
    ' return round(price * (1.0 – rate), 2)\\n', encoding="utf-8")

    ok, err = validate({"price": 90, "rate": 0.1}, type("M", (BaseModel,), {
    "__annotations__": {"price": float, "rate": float}}))
    print("validate ok:", ok, err)
    ok2, err2 = validate({"price": "abc"}, type("M2", (BaseModel,), {
    "__annotations__": {"price": float}}))
    print("validate bad:", ok2, "|", err2.replace("\\n", " / "))

    for item in build_api_docs(d):
    print(f"{item['name']}({', '.join(p['name'] + ': ' + p['type'] for p in item['params'])})"
    f" -> {item['returns']}")
    print(" params:", json.dumps(item["params"], ensure_ascii=False))
    print(" doc:", item["doc"], "| token cost: 0")

    输出:

    validate ok: True
    validate bad: False | price: Input should be a valid number, unable to parse string as a number
    apply_discount(price: float, rate: float = 0.1) -> float
    params: [{"name": "price", "type": "float", "default": null}, {"name": "rate", "type": "float", "default": "0.1"}]
    doc: 按比率打折。rate 是 0~1 的浮点数。 | token cost: 0

    默认值 0.1 是从 AST 里字面读出来的,不是模型猜的。这一条看着琐碎,但在一个有 300 个 API 的文档站里,「猜错默认值」是最常见也最难被发现的一类错误。

    六、结果对比与数据分析

    6.1 实测环境与方法

    在内部一个中等规模 Python 仓库上跑了 60 个真实 bug 修复任务(21k 行代码、1400 个单测)。对比四档配置,每档跑同样的 60 个任务,顺序固定,避免顺序效应。

    配置沙箱回滚断言预算错误栈压缩
    A 裸模型 无 无 模型自述 无 无
    B 仅断言 无 无 pytest L3 无 无
    C 断言+预算 无 无 L0-L3 步数 8 无
    D 完整 harness 有 git 快照 L0-L3 + 门禁 四维 + 软压力 有

    6.2 主结果

    指标A 裸模型B 仅断言C 断言+预算D 完整 harness
    任务成功率 31.7% 48.3% 63.3% 71.7%
    平均迭代步数 1.0 5.8 6.4 4.1
    平均 token/任务 9.4k 21.7k 24.1k 14.3k
    平均成本/任务(USD) 0.041 0.094 0.105 0.062
    P50 墙钟(s) 11 74 86 61
    P95 墙钟(s) 34 291 268 174
    越权文件写入次数 7 5 4 0
    需人工清理的脏工作区 23 11 9 0

    几个必须解释的点:

    D 的成功率比 C 高,但步数比 C 少,看起来矛盾,其实不然。C 配置里那些多出来的步数,绝大部分是「模型在原地打转」——没有错误栈压缩时,模型被日志噪声带偏,去修 warning;没有沙箱时,它甚至能改到自己上一轮写坏的中间状态。D 的 non_progress 检测把这些无效步数直接砍掉了。

    D 的 token 比 B/C 少 40%,几乎全部来自错误栈压缩(每轮回喂从约 9k 字符降到约 1.2k)和确定性替换(5 个环节的 token 归零)。这一项其实是纯赚:成功率没降,成本降了,延迟也降了。

    越权文件写入从 7 次降到 0 次是沙箱 + 路径正则 + 测试只读三层的共同结果。单独任何一层都不够:只有沙箱,模型还能改 tests/;只有正则,src/ 内部越权管不了。

    6.3 迭代上限的敏感度

    max_steps 是一个必须调的参数,但不是越大越好。

    max_steps成功率平均成本(USD)边际成功率增益边际成本(USD)
    2 44.9% 0.028 — —
    4 62.1% 0.049 +17.2% 0.021
    6 69.4% 0.068 +7.3% 0.019
    8 71.7% 0.062 +2.3% -0.006(non_progress 提前停)
    12 72.0% 0.081 +0.3% 0.019
    16 72.1% 0.098 +0.1% 0.017

    注意两个反常行:8 步的平均成本低于 6 步,因为 non_progress 检测在更大预算下有机会触发,反而提前收敛;12 步之后边际增益跌到 0.3% 以下,说明这个仓库的任务复杂度分布里,超过 10 步能解的问题本来就是少数——那些问题通常不是「多试几次」能解的,而是任务描述本身不够清楚。

    我的默认值是 8 步 + 0.5 USD 双上限。低于 6 会明显掉成功率,高于 12 是浪费。

    6.4 失败原因分布(D 配置,17 个失败任务)

    stop_reason数量占比典型情形
    non_progress 6 35.3% 连续两轮指纹相同,模型在改同一个地方
    steps_exhausted 4 23.5% 需求涉及跨模块重构,一次性改不动
    cost_tripped 3 17.6% 任务触发了大范围类型改动,mypy 反复报错
    flaky_assertion 2 11.8% 测试本身依赖时间与随机数
    gate_regression 1 5.9% 局部修好了,但破坏了另一个模块的契约
    sandbox_killed 1 5.9% 生成的代码里有死循环,CPU rlimit 触发

    non_progress 占第一位,这恰恰说明这个信号抓对了:它把本来会以 steps_exhausted 结束、白烧 6 步的任务提前判死。

    七、踩坑记录与排查手册

    八条,全部是真实踩过的。格式固定为「现象 / 定位 / 原因 / 解决」。

    7.1 沙箱逃逸:子进程通过环境变量拿到了宿主凭证

    现象:一个「重命名工具类」的简单任务,harness 报告 solved,但事后在审计日志里发现任务期间有一次对 169.254.169.254(云元数据服务)的请求。任务本身不需要任何网络。

    定位:在沙箱里跑 env | grep -i -E "aws|ssh|kube|token",输出里有 AWS_PROFILE=prod、SSH_AUTH_SOCK=/tmp/ssh-xxx/agent.1234、KUBECONFIG=/home/me/.kube/config。也就是说,tempfile.mkdtemp() 只隔离了工作目录,没有隔离身份。

    原因:subprocess.run 默认继承父进程完整环境。我把 repo 拷进了临时目录,却没有动 os.environ。模型生成的任何一段脚本都能读到这些变量,而 RLIMIT_AS 和网络策略(当时还没加 unshare)都管不到「读一个环境变量」这个动作。

    解决:三重处理,缺一不可。

  • 环境白名单 _clean_env(),只保留 PATH/LANG/TZ 等,显式 pop 掉凭证类变量(见 5.2)。
  • HOME 强制指向沙箱内的空目录,让 ~/.aws、~/.ssh、~/.config/gcloud 一次性全部失效。这一条比逐个 pop 更彻底。
  • Linux 上用 unshare -n 建无网络命名空间;不支持的平台打印降级警告,绝不静默。
  • 验证方式:在沙箱里跑 python -c "import os,socket;print(os.environ.get('AWS_PROFILE'));socket.create_connection(('1.1.1.1',80),3)",应当抛异常或超时。

    7.2 flaky 测试让循环在两个状态之间震荡

    现象:一个任务的成本是同类任务的 4 倍,8 步跑满,最后 steps_exhausted 失败。逐轮看报告,发现失败节点在 test_order_total 和 test_order_total_concurrent 之间来回跳,错误类型一会儿 AssertionError 一会儿 TimeoutError。

    定位:把这两个测试单独拿出来连跑 10 次,退出码有 0 有 1。是测试本身不稳定,不是代码有问题。

    原因:test_order_total_concurrent 依赖 time.time() 和线程池调度,在负载高的时候会超时。harness 把「断言失败」无条件等价为「模型写错了」,于是模型被要求去修一个它根本没碰过的并发测试,越修越乱。

    解决:在断言层加 looks_flaky()(见 5.4)——同一个节点连跑 3 次,退出码不一致就判定 flaky,直接置 stop_reason=flaky_assertion 并停止循环,不把锅甩给模型。同时把 flaky 测试打上 @pytest.mark.flaky 并移出 L3 目标层,只保留在 L5 全量里作为参考(不计入门禁)。

    这条坑的教训是通用的:**任何自动化修复循环都必须先验证裁判是可靠的,再让选手上场。**裁判不可靠的时候,循环跑得越久错得越离谱。

    7.3 迭代上限设成 3,任务大批「直接放弃」

    现象:初版 harness 上线第一天,成功率只有 44.9%。看失败分布,一大半是 steps_exhausted,而且它们的报告里往往只有 2 到 3 轮迭代,最后那轮的失败栈看起来「快要修好了」。

    定位:我把 max_steps 默认值设成了 3,理由是「控制成本」。

    原因:成本焦虑导致预算过紧。从 6.3 的敏感度表可以读出,这个仓库上从 2 步到 6 步的边际成功率增益是 +24.5%,边际成本只有 0.04 USD;而 12 步之后边际增益跌到 0.3%。预算曲线有一个明显的膝点,把上限设在膝点左边是纯亏。

    解决:默认改成 8 步 + 0.5 USD 双上限,并且用 non_progress 检测把「打转」的任务提前判死。这样既不会过早放弃,也不会让无解任务烧满预算。另外一个配套动作:在报告里记录每一轮的 fingerprint,隔一周回头看哪些指纹反复出现——那些通常是需求描述不清的任务,应该回到人那里拆细,而不是靠加预算硬扛。

    7.4 回滚把未跟踪文件删没了

    现象:一次失败的自动修复之后,开发者发现自己本地的 .env.local 和一份写了两天还没提交的迁移脚本 migrations/2026_09_add_tenant.sql 一起消失了。

    定位:回滚代码是 git checkout . && git clean -fd。

    原因:git clean -fd 的定义就是「删除所有未跟踪文件」,它不区分这个文件是模型新建的还是开发者本来就有的。git checkout . 也只恢复已跟踪文件。两个命令合起来,把「任务开始前就存在的未跟踪文件」这一整类状态无差别抹掉了。

    解决:改用 5.3 的快照方案——先 git add -A 让未跟踪文件进入索引,再 git diff –cached –binary 生成包含新建文件的完整 patch,回滚时 git apply -R。git apply -R 对「new file mode」段落的处理是删除该文件,所以它天然区分了「新建的(该删)」和「本来就有的(该留)」。另外加了两条保险:回滚前先 git apply –check -R 验证;快照时把 run_id 写进 patch 文件名,保留 7 天,方便人工找回。

    如果你现在的代码里还有 git clean -fd,今天就改掉。

    7.5 错误栈太长,挤爆上下文并带偏模型

    现象:第 3 轮之后模型开始「跑题」——它去修一些和任务完全无关的 warning,甚至在报告里抱怨「依赖版本不兼容」。任务的真实失败点在第 1 行就已经明确了。

    定位:打印回喂给模型的字符串长度,平均 9400 字符,最高 38000。里面 70% 是 Requirement already satisfied、Building wheel for xxx 这类噪声。

    原因:两条叠加。一是我把 pytest 的完整 stdout 原样回喂;二是上下文被噪声占满之后,模型能「看到」的真实失败信息反而变少,于是它只能从噪声里挑一个看起来像问题的问题去修。这是一个正反馈的跑偏。

    解决:实现 compress()(见 5.4):丢掉噪声行、保留头部 12 行与尾部断言相关行、总长度硬上限 4000 字符。压缩后平均回喂约 1200 字符。实测结果是成功率不降反升(因为模型不再被带偏),同时 token 消耗下降约 40%。

    补充一条经验:错误栈要保留「精确位置」而不是「完整过程」。模型需要的是 src/pricing.py:42: AssertionError: assert 90 == 81,不是前面 200 行的 fixture 构造过程。

    7.6 模型改测试而不是改代码

    现象:任务报告 solved=True,门禁全绿,但 PR 里 tests/test_cart.py 被改了——断言从 assert total == 81 变成了 assert total == pytest.approx(81, abs=1)。功能没修,判据被放宽了。

    定位:git diff 显示测试文件的改动来自模型的 patch。检查 harness,发现沙箱工作区里的 tests/ 是可写的。

    原因:我只用提示词约束「不要改测试」,没有在文件系统层面禁止。提示词是建议,模型在「改代码改不动」的时候会自然滑向「改判据」——因为从它的视角看,这两条路都能让断言变绿。

    解决:三层防护。

  • FileEdit.path 正则限制 + Generator/Fixer 提示词明确禁止(5.5)。
  • 沙箱里对 tests/ 下所有文件 chmod 0o444,模型写不进去(5.6 的 _apply)。
  • _commit() 回写宿主时跳过 tests/ 路径,双保险。
  • 这是「确定性断言」这条边界的核心。判据必须物理上不可写,否则它就不是判据,只是一个默认值。

    7.7 成本熔断只算了模型 token,工具侧跑冒了

    现象:一个任务的模型 token 只花了 0.06 USD,但整个任务跑了 22 分钟,占了 CI 一整台机器。

    定位:看 RunReport 的 iterations,每轮 L3 目标测试的 duration_ms 都在 90 秒以上。模型侧预算没触顶,所以循环一直继续。

    原因:Budget 只记了 token 和钱,没记墙钟。工具侧的成本(跑测试、跑 mypy)在 token 账上完全不可见。而 max_wall_clock_s 当时设成了 600,太宽松。

    解决:把墙钟预算从 600 降到 240,并且在 Budget.pressure() 里把墙钟也纳入(5.1 已经是四维度)。更关键的是把工具耗时显式记账:每一轮把 iteration.duration_ms 累加,单独出一个 tool_seconds 指标。现在我的看板上有两条线,model_cost_usd 和 tool_seconds,任何一个异常都能立刻定位是哪一侧。

    7.8 RLIMIT_AS 设太小,CPython 起不来

    现象:把 address_space_mb 从 2048 调到 512 想省内存,结果所有沙箱命令的退出码都是 1,stdout 和 stderr 全空。

    定位:在沙箱外手动跑 ulimit -v 524288 && python -c "print(1)",同样退出码 1 无输出。

    原因:CPython 解释器启动时会预留相当大一片虚拟地址空间(arena、线程栈、importlib 的映射),RLIMIT_AS 限的是虚拟地址空间而不是物理内存。512MB 的虚拟地址上限在解释器完成初始化之前就被打满了,进程在能打印任何错误信息之前就被 SIGKILL 掉。

    解决:address_space_mb 不低于 1536(我默认 2048)。需要更严格的内存限制时,不要继续压 RLIMIT_AS,改成下面两种之一:

    • 用 cgroup v2 的 memory.max 限物理内存(这是正确的量)。
    • 用 RLIMIT_DATA(堆)配合 RLIMIT_AS 放宽,但 Python 上效果不稳,不推荐。

    通用排查技巧:沙箱命令退出码非 0 且 stdout/stderr 全空时,先怀疑 rlimit,再怀疑命令本身。在 RunResult 里保留 killed_by 字段就是为了这一刻——rlimit_cpu/rlimit_mem/timeout 三个值能让这类问题一眼看出。

    7.9 踩坑速查表

    现象一句话定位命令根因分类修复位置
    沙箱里能读到宿主凭证 env | grep -i aws 环境继承 _clean_env() + HOME 重定向
    循环在两个失败点间跳 单测连跑 3 次 flaky 断言 looks_flaky() 提前停
    大批任务过早放弃 看 stop_reason 分布 预算过紧 max_steps 调到膝点右侧
    本地未提交文件消失 git status 前后对比 git clean -fd 改用 git apply -R
    模型修无关的东西 打印回喂长度 上下文噪声 compress()
    测试断言被放宽 git diff tests/ 判据可写 chmod 0o444 + 回写跳过
    任务耗时长但很便宜 看 tool_seconds 工具侧成本没记账 墙钟纳入 pressure()
    沙箱命令无输出退出 1 手动 ulimit -v 虚拟地址上限 address_space_mb >= 1536

    八、优化方案与进阶打法

    8.1 动态 Skill 加载 + Prompt Caching 压 TTFT

    把 30 个 Skill 的定义全塞进 system prompt,前缀会有 8000 到 15000 token,每次调用都要重新 prefill。Anthropic 蓝图给的做法是反过来的:

    静态前缀(可缓存,跨任务复用):
    [身份] [安全红线] [输出 schema] [工具签名]
    -> 这段必须每次调用完全逐字节一致,才能命中 Prompt Caching

    动态挂载(不可缓存,按需注入):
    [本次任务相关 Skill 1..3] [检索到的文件片段] [压缩后的错误栈]
    -> 这段每轮都在变,放在最后,避免污染前缀

    三条实操约束:

    • **静态前缀必须逐字节稳定。**任何时间戳、随机 run_id、版本号拼接进 system prompt 都会导致缓存 miss。我把 run_id 挪到了 user message 里。
    • **Skill 挂载要有上限。**一次最多挂 3 个,超过就先做一轮「选 Skill」的便宜模型调用。挂 10 个 Skill 的收益远低于成本。
    • **缓存有效期要对齐你的迭代节奏。**大部分网关的缓存 TTL 是 5 分钟,而一轮迭代(模型调用 + 沙箱跑测试)往往超过 5 分钟。解决办法是把断言层做快:L0 用 ruff(约 50ms)而不是等全量测试,让 TTFT 的热路径留在 TTL 内。

    实测:静态前缀 6200 token 稳定后,命中缓存的单轮 TTFT 从约 1.8s 降到约 0.6s,8 步任务累计省下约 9s;成本上缓存命中的输入 token 通常有大幅折扣,整体再降约 30%。

    8.2 UI 组件作为工具输出

    工具返回纯文本时,「模型理解」和「人理解」用的是同一份材料,而这两件事的最优表示完全不同。让工具返回一个组件描述:

    {
    "component": "DiffReview",
    "props": {
    "files": [{"path": "src/pricing.py", "added": 6, "removed": 2}],
    "assertions": [{"name": "L3_target", "ok": true, "ms": 3120}],
    "budget": {"steps": "2/8", "cost": "$0.017 / $0.50"},
    "actions": [{"id": "approve", "label": "合并"}, {"id": "reject", "label": "回滚"}]
    }
    }

    前端渲染成一个真实的 diff 视图 + 预算进度条 + 两个按钮。好处有三:人不用读模型写的自然语言总结(那总结可能撒谎);操作入口是结构化 action id,不是「请帮我合并」;预算状态对人可见,人能在成本失控前按停。

    8.3 harness 层强制安全拦截与人工签署

    不可逆操作必须在 harness 层被拦,提示词不算。实现方式是工具注册表的两级白名单:

    工具类别例子是否需人工签署harness 行为
    只读 read_file、grep、list_dir 否 直接放行,记 journal
    沙箱内可逆写 write_file(沙箱内)、run_tests 否 放行,记 journal,快照覆盖
    宿主可回滚写 apply_patch(有快照) 否,但需 PR 放行,必须有 Snapshot
    不可逆 db_migrate、send_email、pay、delete_resource 是 生成提案 -> 阻断 -> 待人签署
    网络出口 http_request 视域名白名单 白名单外直接拒绝

    PatchProposal.touches_external=True 时,harness 不执行,而是把提案序列化成一份待签署对象(含完整 diff、影响面、幂等键),推给审批端。人批准后才走一次真实调用,调用时必须带上事先生成的 idempotency_key,并写进 journal。

    这里呼应 Anthropic 蓝图第三条:**拦截点在 harness 层,不在提示词层。**提示词里的禁令会被提示词注入绕过,注册表里的缺失不会。

    8.4 断言选择:别跑全量,跑受影响子集

    循环里跑全量测试是最常见的性能错误。三种渐进做法:

  • 手工 target:任务自带 –target,最省事,覆盖大部分 bug 修复场景。
  • import 图反查:从改动文件的 import 关系反查出依赖它的测试文件。
  • 覆盖率反查:维护一份 test -> covered_files 的映射(用 pytest-cov 的 json 报告生成),改动文件映射到测试集合。这是最准的,映射本身每天重建一次即可。
  • 第 3 种在我们的仓库上把 L3 的测试数量从平均 340 个降到平均 17 个,单轮断言耗时从 92s 降到 4.3s。这一项对 6.2 表里 D 配置的 P50 墙钟(61s)贡献最大。

    8.5 并行候选

    当单条路径的收敛不稳定时,可以在一轮里让模型产出 3 个候选 patch(n=3),并行丢进 3 个沙箱跑断言,取第一个通过的。成本是 3 倍模型调用,但沙箱可以并行,墙钟基本不变。

    适用条件:断言跑得快(L3 目标测试在 5 秒以内)且预算充足。不适合 L5 全量测试做门禁的场景——3 个候选 x 5 分钟全量测试,墙钟会炸。我的规则是 pressure() < 0.5 且 L3 耗时 < 10s 时才开并行候选。

    8.6 沙箱复用与预热

    每次迭代重建临时目录 + copytree 一个 21k 行的仓库,在我的机器上要 1.8s,8 轮就是 14s 纯开销。优化两条:

    • 同任务内复用同一个沙箱,每轮开始前用快照的 patch 反向恢复工作区,而不是重建。
    • 预热:把 .venv 和 node_modules 放在沙箱外的一个只读层,用符号链接或 bind mount 挂进去。注意符号链接有逃逸风险,用 copytree(symlinks=False) 或直接 bind mount,不要用软链。

    注意复用带来的副作用:**上一轮留下的 __pycache__ 和临时文件会污染下一轮。**每轮开始前必须做一次 git clean -fdx -e .venv(保留预热的依赖目录)。

    九、适用场景与选型建议

    9.1 适合上 harness 的场景

    场景为什么适合预算建议关键配置
    单测覆盖良好的 bug 修复 断言天然存在且可靠 8 步 / 0.5 USD L3 目标测试驱动
    依赖升级与类型修复 mypy / 编译器是完美裁判 12 步 / 1.0 USD L1 断言前置
    lint 违规批量清理 ruff 有 –fix,判据 100% 确定 4 步 / 0.1 USD 可跳过模型,直接确定性修
    脚手架与样板代码生成 编译通过即可判定 6 步 / 0.3 USD L2 断言
    测试补全 新测试必须能失败后通过 8 步 / 0.5 USD 需变异测试验证

    9.2 不该用 harness 的场景(这一节比上一节重要)

    **第一,没有可靠断言的领域,不要上自动化循环。**UI 视觉调整、文案润色、交互体验优化——这些领域的「对不对」没有确定性裁判,你只能让人看。硬要上循环,结果就是模型自评自改,成本烧完得到一个你说不清好不好的结果。我的判断标准很粗暴:如果这个领域你写不出一个能在 60 秒内跑完、退出码非 0 即失败的检查脚本,就别开循环。

    **第二,需求描述不清的任务,加预算没用。**6.4 的失败分布里 non_progress 占 35.3%,这些任务回头看几乎都是需求本身含糊(“把这个模块优化一下”)。这类任务应该回到人那里拆细,而不是靠 max_steps=16 硬扛。

    **第三,涉及不可逆外部写操作的场景,先有补偿机制再谈自动化。**没有幂等键、没有补偿事务、没有审批流的系统,接 Agent 等于裸奔。这时候 harness 能做的上限是「生成提案等人批」,那就老实做成提案系统,别伪装成自动修复。

    **第四,超小改动不值得开 harness。**改一行常量、改一个配置值,开一轮 harness 的成本(快照 + 沙箱 + 断言)可能比人工改还贵。我设了一条阈值:改动面小于 3 行且无测试依赖时,直接走普通的模型辅助编辑。

    **第五,强合规行业要先解决审计。**如果你的行业要求每一次生产变更都能追溯到具体的人和操作依据,那么「模型生成 + 自动合并」这套流程在合规上是不成立的,必须保留人工签署环节。这恰恰是 Anthropic 蓝图第三条要解决的问题。

    9.3 与编程 Agent 产品的关系

    2026 年主流的编程 Agent 产品(Codex 系、Claude Code 系等)内部都已经有了各自的 harness,差异主要在三点:默认预算、沙箱强度、以及是否允许模型改测试。选型时我最看重的是能不能把它的 harness 参数暴露给我调。一个不允许你配置 max_steps 和断言命令的产品,在团队级使用上风险很高——你没法把它的行为纳入自己的成本与合规框架。

    自建 harness(本文这套)的适用前提是:你有明确的断言体系、有 CI、愿意维护这套壳子。如果你只是想让个人开发效率高一点,直接用成熟产品,别自己造。

    十、总结与展望

    回到开头那句话:LLM 是非确定性的 token 生成器,不应该被授予对生产 OS 与数据库的中介化访问权。这篇文章给的不是「怎么让模型更靠谱」,而是「怎么让模型不靠谱也不出事」:

    • 四条确定性边界:编排控制器(步数与终止条件)、隔离执行沙箱(临时文件系统 + 受限容器)、状态持久化与回滚(改动与工具调用可 checkpoint / revert)、确定性断言(编译器 / linter / 单测作为不可通融的裁判)。顺序不能乱,先有回滚才敢有重试。
    • 自动化修复循环:生成代码、丢进沙箱跑测试、失败把压缩后的精确错误栈回喂模型、固定迭代上限。人不再逐行 review,而是 review 边界配置和最终 diff。
    • 三分立与 schema 强制:生成 / 评估 / 修复三个角色看到的信息严格隔离,模型不能给自己生成的代码打分;所有输出过 JSON Schema,闲聊污染不了机器消费的 payload。
    • 概率生成该被确定性代码替换的地方就替换:Schema Validator、Documentation Builder 这类环节用零 LLM 调用的确定性实现,直接消灭两类幻觉(假阳性校验、编造的参数),并把该阶段 token 消耗降到 0。
    • 步数预算与成本熔断:四个维度独立计(步数 / 墙钟 / token / 成本),加一个 non_progress 指纹检测和一档软压力降级策略。

    实测数据里最值得记住的两个数字:**完整 harness 相比「仅断言」配置,成功率 +23.4 个百分点,而成本反而下降 34%。**这说明之前多花的钱不是花在「让模型更聪明」上,而是花在了模型打转和噪声上下文上。

    展望三条。第一,断言的质量和覆盖率会取代模型能力,成为 Agent 编程系统的真正瓶颈——你现在往测试上投的每一分钱,未来都会变成 Agent 可用的判据。第二,harness 的标准化正在发生,中国信通院 2026-09-29 的《可信互联网智能体 运行时 上下文工程》标准和 CIS 在 2026-09-16 给出的 MCP 审计基线,都是在给这一层壳子定规矩;能把自己的 harness 映射到这些基线上,会成为团队级落地的入场券。第三,人机界面会成为新的分水岭,UI 组件作为工具输出 + harness 层人工签署,会让「Agent 能做什么」从技术问题变成产品设计问题。

    最后给一条可执行的起步路径:**不要一上来就搭完整的 harness。**按顺序做四步——先把 git clean -fd 从你的回滚代码里删掉(7.4);再把错误栈压缩加上(7.5);然后给测试目录加只读保护(7.6);最后再补预算与沙箱。这四步的性价比是按这个顺序递减的,但每一步都能独立生效。

    参考素材与延伸阅读

    • Google ADK 2.0 / Antigravity SDK 的 Harness Engineering 阐述与 Anthropic 商务 Agent 蓝图(动态 Skill 加载、UI 组件作为工具输出、harness 层安全拦截) — AtlasNote 每日简报,2026-09-03
    • 九 Agent 流水线如何在生产规模上解决可靠性问题 — Grafex Media
    • 2026 年主流编程 Agent 横评:Codex 与 Claude Code — Mem0 Blog
    • CIS MCP Benchmark 为 Agent 工具治理设定 55 点审计基线 — AI Governance,2026-09-16
    • 中国信通院《可信互联网智能体 运行时 上下文工程》标准 — 澎湃新闻,2026-09-29
    • LLM 生产可观测性的工具分层 — Unico Connect
    • Agent 上下文三级压缩机制 — 腾讯新闻,2026-09-22
    • 多 Agent 架构向 MCP + A2A 协议收敛 — The Agent Times
    • Reciprocal Rank Fusion 原始论文(Cormack, Clarke, Buettcher, SIGIR 2009) — ACM DL,2009
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Harness Engineering:把随机的模型关进确定性的壳子里
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!