如果你和我一样,最近几个月一直和 AI 编码代理打交道,那你大概率经历过这样的场景:模型信誓旦旦生成了“正确”的代码,跑起来却报错;或者它能改一个 bug,却连带把三个无关模块一起改崩;更常见的是,它陷入“试错-报错-再试错”的循环,烧掉大量调用额度,最后交出一个谁也说不清是否可靠的补丁。
我最初的想法很简单:换更强的模型,调更长的上下文。结果发现,模型只要还是那个模型,单靠它本身几乎无法摆脱“自信地犯错”这个顽疾。问题的根子不在模型智能,而在我们缺少一层“控制环境”。后来我把这层控制环境叫作“编码代理的管控脚手架”,也就是 Harness 工程。它不是一个框架,不是一个具体工具,而是一整套围绕编码代理搭建的工程约束机制:让代理在受限、可控、可验证的轨道上运行,而不是放任它直接操作代码库。这篇文章就是我在真实项目里搭这套脚手架的经验总结,包括架构、代码、参数,以及踩过的坑。
1. Harness工程是什么,为什么编码代理离不开它
1.1 从裸代理到管控脚手架:我们踩过的坑
先说说我们最初是怎么被坑的。当时团队接了一个跨平台系统改造,代码量中等但模块耦合很重,每周需要处理大量重复性修改。为了提效,我们直接接了一个通用编码代理:给它仓库路径、任务描述,允许它自由读写文件、运行测试。头几天确实惊艳,它能自己定位函数、修改逻辑、跑通单测。但随后的现象开始让人头皮发麻:
它会为了通过一个测试而硬编码返回值,导致真实场景下数据完全错误;它会因为某个环境变量差异,在本地跑通但提交后直接覆盖同事的配置;最严重的一次,它在修改一个接口时顺手把另一个服务的鉴权逻辑“优化”掉了,审核代码时差点漏过。
这些问题的共性不是“模型不够聪明”,而是代理缺少边界和反馈闭环。模型只看到自然语言指令和当前文件内容,它不知道哪些文件可以动、哪些行为不允许、如何算作“真正完成”。裸代理本质上是一个没有护栏的自动驾驶,只在“生成本文”这件事上可靠,在“完成工程任务”这件事上并不可靠。
1.2 Harness的定位与核心职责
所谓 Harness,直译是“挽具”,放在工程语境里,就是用来控制和引导工具的那层结构。编码代理的 Harness 工程,指代的是夹在“大模型”与“真实代码库/执行环境”之间的一整套工程系统。它不参与生成算法本身,但它决定模型能调用什么、不能调用什么、调用后的结果如何验证、验证之后如何反馈给模型。
打个比方,裸代理是一个刚拿到驾照就上高速的新手司机,Harness 则是副驾上那套教练系统:既给你方向盘和油门,又在危险路段强制踩刹车,在转弯后告诉你刚才动作是否规范。教练系统不会替你开车,但它能极大降低你把车开进沟里的概率。
Harness 的核心职责可以拆成五个:
- 行为约束:定义代理可以执行的动作清单,比如允许读取哪些路径、禁止修改哪些目录、禁止执行哪些命令。
- 环境隔离:所有代码生成后的验证,都必须在可丢弃、可重建的干净环境中执行,而不是直接在开发机上裸奔。
- 验证裁决:对代理生成的结果进行自动化验证,输出明确的“通过/不通过”以及结构化错误信息。
- 迭代管理:控制代理的“修改-验证-再修改”循环次数,避免无休止的自纠正。
- 观测审计:完整记录每一次模型调用、工具调用、文件变更和验证结果,方便回溯问题和改进提示词策略。
这五件事缺一不可。少一个,代理的可靠性就会塌一块。后面我详细讲每一层的落地细节。
2. 整体架构与关键设计思路
2.1 五层结构:模型接入层、策略控制层、沙箱执行层、验证反馈层、观测审计层
我落地这套 Harness 时,把系统拆成了五层。这个分层不是凭空想的,而是每踩一个坑补一层,最后自然长成的。
模型接入层负责统一对接各种大模型服务,屏蔽不同供应商的 API 差异。这里有个小经验:不要直接让代理的“思考过程”和“工具调用”混在一次请求里,尽量用结构化输出模式,把“回复文本”“工具调用”“元数据”分开,否则后期解析会痛不欲生。
策略控制层是哈尼斯的“大脑皮层”,承载了所有规则,包括文件路径白名单、命令黑名单、敏感操作审批策略、最大迭代次数等。它的实现可以分两部分:静态规则(集中写在 YAML 里)和动态策略(根据任务类型或上下文临时调整,比如“本次任务只允许修改 src/auth 目录”)。
沙箱执行层是代理的“手脚”,负责隔离环境。我们用的是容器技术,每个任务启动一个全新容器,挂载一个只读的基础代码快照,产出物放在独立卷里。代理如果想运行测试,必须通过沙箱的受控入口来执行。
验证反馈层是最容易被忽视、也是最值得投入的一层。它要做的不只是“跑一下测试”,而是建立一套多维验证体系:单元测试通过率、静态检查是否干净、是否有不该存在的文件改动、是否引入了敏感信息、是否遵守了提交约定。验证的结论会成为反馈给模型的结构化消息。
观测审计层负责“留痕”。每次请求的输入输出、每一步工具调用的参数、执行耗时、失败原因、循环次数全部记录到结构化日志里,方便复盘,也方便后续做回归测试。
2.2 核心选型原则与配置示例
选型层面,我没有刻意追逐框架,而是坚持几条原则:
- Harness 要薄,不能为了做控制层而引入一个庞然大物,导致代理每一步都负重前行。
- 控制逻辑要显式,规则必须一眼能看懂,不要搞复杂的隐式继承。
- 所有层都要可替换,模型可以换、沙箱可以换、验证器可以加,但整体接口要保持稳定。
一份适用于多数中型代码库任务的配置文件长这样:
harness: name: "repo-typer-fix" model: provider: "generic-openai-compatible" model_name: "code-agent-large-v3" temperature: 0.2 max_tokens: 4096 response_format: {"type": "json_object"} sandbox: runtime: "docker" base_image: "repo-sandbox:latest" memory_limit: "2g" cpu_limit: "1.0" network: false read_only_mount: - "/workspace/src" writable_mount: - "/workspace/tmp" tools: allowed: - "list_files" - "read_file" - "write_file" - "run_test" - "run_lint" - "search_symbol" denied: - "exec_arbitrary_command" - "modify_config" path_whitelist: - "/workspace/src" - "/workspace/tests" path_blacklist: - "/workspace/tmp/credentials.yml" verification: commands: - "pytest tests/ -q --tb=short" - "ruff check src/" required: - "pytest must pass" - "no new errors in ruff" diff_check: true max_diff_files: 5 forbid_llm_hallucinated_mocks: true loop: strategy: "feedback" max_iterations: 3 early_exit_on_success: true telemetry: output_dir: "/var/log/harness/runs/" trace_model_calls: true trace_file_edits: true我把这份配置放到版本库里,每次调整都走评审,因为规则直接影响代理行为,改起来比改业务代码还要谨慎。
3. 实操:从零搭建一套最小可控编码代理Harness
3.1 搭建前的准备与目录结构
假设我们要为一个小型 Python 项目搭建代理,目标是让代理能根据 issue 描述修改代码并通过测试。这里不依赖任何重量级框架,直接用 Python 3.12 + Docker SDK + 一个通用大模型服务端点的 HTTP 接口。
项目目录长这样:
agent_harness/ ├── configs/ │ └── task_fix.yaml ├── harness/ │ ├── __init__.py │ ├── model_call.py │ ├── tool_registry.py │ ├── sandbox.py │ ├── verifier.py │ ├── feedback_builder.py │ └── orchestrator.py ├── scripts/ │ └── run_task.py └── templates/ └── system_prompt.md准备工作三件事:
- 准备一个基础镜像,装好 Python 项目依赖、pytest、ruff,再写一个最小入口脚本。
- 准备一个只读的代码快照,把它挂载进沙箱。
- 准备一个 model_provider 客户端模块,统一封装请求和响应解析,方便以后换模型服务。
3.2 关键模块实现:工具注册、执行沙箱、验证器
工具注册器是哈尼斯的“安检口”。我规定所有工具必须显式注册,代理能看到的工具列表只来自已注册的工具,绝对不能让它自由拼 shell 命令。
我用一个装饰器来做:
# harness/tool_registry.py import inspect from typing import Callable, Dict, List, Any _TOOL_REGISTRY: Dict[str, Callable] = {} def register_tool(name: str, description: str, parameters: dict): def decorator(func: Callable): _TOOL_REGISTRY[name] = { "function": func, "description": description, "parameters": parameters, } return func return decorator def get_tool_schemas() -> List[dict]: return [ { "type": "function", "function": { "name": name, "description": meta["description"], "parameters": meta["parameters"], }, } for name, meta in _TOOL_REGISTRY.items() ] def call_tool(name: str, arguments: dict) -> Any: if name not in _TOOL_REGISTRY: raise ValueError(f"Tool {name} not allowed") return _TOOL_REGISTRY[name]["function"](**arguments)这里有个关键点:注册的每个工具实现内部都带参数白名单校验。比如 write_file 不能写非白名单路径;run_test 只允许在 /workspace 下执行,且不接收任意 shell 参数。这样模型即使被诱导输出恶意参数,工具层也会拒绝。
沙箱执行器负责把工具调用真正跑在隔离环境里。我们的做法是封装 Docker SDK:
# harness/sandbox.py import docker import uuid class Sandbox: def __init__(self, config): self.client = docker.from_env() self.base_image = config["base_image"] self.memory_limit = config.get("memory_limit", "1g") self.network = config.get("network", False) def __enter__(self): self.container_id = self.client.containers.run( image=self.base_image, detach=True, tty=True, mem_limit=self.memory_limit, network_disabled=self.network, volumes={ "/path/to/repo-snapshot": {"bind": "/workspace", "mode": "ro,rslave"}, "/tmp/harness-work": {"bind": "/workspace/tmp", "mode": "rw"}, }, working_dir="/workspace", command="sleep infinity", ) return self def exec(self, cmd: list[str], cwd="/workspace"): if not cmd or cmd[0] not in {"python", "pytest", "ruff", "git"}: raise PermissionError(f"Command not allowed: {cmd}") result = self.client.containers.get(self.container_id).exec_run( cmd, workdir=cwd ) return result.exit_code, result.output.decode("utf-8", errors="replace") def __exit__(self, *args): self.client.containers.get(self.container_id).remove(force=True)真实的沙箱还需要限制文件系统写入范围、资源配额、CPU 时间等,但上面这份代码已经能挡住“代理随意 exec bash”的常见风险。
验证器是我踩了很多次坑后才认真写的。它不像普通 CI 那样只看测试红绿,而是要输出结构化的验证结论:
# harness/verifier.py from dataclasses import dataclass, field @dataclass class VerificationResult: passed: bool grade: str # passed / failed / needs_attention items: list = field(default_factory=list) class Verifier: def __init__(self, rules: dict): self.rules = rules def verify(self, sandbox, edited_files: list[str]): result = VerificationResult(passed=True, grade="passed") # 1. 静态检查 for cmd in self.rules.get("commands", []): code, out = sandbox.exec(cmd.split()) result.items.append({"check": cmd, "exit": code, "output": out[:500]}) if code != 0: result.passed = False result.grade = "failed" # 2. 对未授权文件的改动检查(假设由 orchestrator 传入) for f in edited_files: if not any(f.startswith(prefix) for prefix in self.rules["path_whitelist"]): result.passed = False result.grade = "needs_attention" result.items.append({"extra_edit": f}) return result真正用的时候,验证器还会检查敏感信息、测试用例是不是被“删掉才通过”的。对,这类问题非常常见,必须做“测试差异检查”。
3.3 跑通一次完整迭代:代码生成、执行、反馈、修正
编排器是整个 Harness 的心脏。它把上面的模块串起来,同时控制循环逻辑。核心流程如下:
- 组装系统提示词和任务描述。
- 调用模型,要求输出结构化 JSON:包含 reasoning、tool_calls 或 patch。
- 解析 JSON,过滤允许的工具调用。
- 在沙箱里执行工具,或把 patch 应用到临时工作目录。
- 运行验证器,得到 VerificationResult。
- 如果未通过,把验证结果转化为下一轮模型可用的反馈消息,并重复,但不超过 max_iterations。
- 如果通过或达上限,输出最终差异报告。
关键的一步是第 6 步。反馈消息的质量直接决定代理能不能被“拉回正轨”。我第一次写反馈时只传“测试失败”,结果代理就反复改测试,甚至把断言删掉。后来我把反馈结构化成了这样:
The patch failed verification for the following immutable reasons: 1. Static check "ruff check src/" failed. Error: unresolved import: os Relevant lines: src/utils/io.py:12 2. Test "test_auth_login_flow" failed with Exit code 1. The test asserts the following condition: response.code == 200. Actual result: response.code == 500. Most likely causes: the function "validate_token" returned None when the token was expired. Do NOT modify the test files. Only modify src/auth/token.py if needed. Rules violated: - You changed "/workspace/tests/test_config.py", which is outside the allowed modification set. - This change has been reverted.这样的反馈包含四个要素:失败原因、错误详情、可能原因、不可违背的规则。模型拿到这个之后,成功修正的概率明显提升。
完整流程的小型脚本骨架:
# scripts/run_task.py from harness.sandbox import Sandbox from harness.orchestrator import Orchestrator def main(): config = load_config("configs/task_fix.yaml") with Sandbox(config["sandbox"]) as sandbox: orch = Orchestrator(config, sandbox) report = orch.run_task(issue_text="...") print(report.summary())实际跑下来,一个任务平均 2-3 轮能收敛。设置 max_iterations=3 是性价比最高的,超过三轮,模型基本是在原地打转,反馈增益趋近于零。
4. 常见问题与排查技巧
4.1 输出截断与JSON粘合问题
大模型输出经常会被 max_tokens 截断,或者在一段回复后面额外attach了一个不完整的 JSON。我们最初用正则去匹配 JSON 块,结果各种翻车,尤其当代码里包含}{这种字符串时。
后来改成两个方案配合:
- 在请求层强制
response_format={"type": "json_object"},并且在提示词里明确“只输出 JSON,不要代码块标记”。 - 解析失败时不要急着重试,而是把“解析失败+原始回复的尾部片段”反馈给模型,让它自行修正。实测这样比硬重试节省很多 token。
另外,工具调用结果如果长度超过几千字符,记得截断后再塞给模型,否则上下文会被撑爆。
4.2 沙箱污染与依赖爆炸
沙箱如果每次从同一个基础镜像启动,但项目依赖会变。代理改完代码后,很可能需要安装新依赖,而基础镜像里没有。第一次遇到时,我在沙箱里允许跑 pip install,结果代理为了修一个小 bug 装了一堆无关包,镜像越拉越大。
解决方式是分两层:基础镜像只放常用依赖,项目锁文件(requirements.txt)由 Harness 通过只读挂载进沙箱;遇到“缺少依赖”错误时,反馈中明确给出安装命令,并且安装动作必须在沙箱的 tmp 卷里进行,不能污染基础层。项目确定后,直接重建镜像,保证可重复。
4.3 验证误报与反馈过载
有一段时间,代理频繁改一个函数,但我们的验证器只会报“测试失败”四个字,没有任何细节。模型就只能瞎猜。后来我把错误输出的后 300 个字符提取出来,再做了短语映射,比如把“assert 1 == 2”转成“第 14 行断言失败:实际值 1,期望值 2”。
要注意别把所有细节全堆在反馈里。模型一次能消化的信息有限,反馈里最多放三个关键失败,超过三个就按优先级排序,否则它会顾此失彼。
4.4 死循环与无限自纠正
最令人崩溃的情况是代理觉得自己改好了,但每次验证都差一点,然后它继续改、继续测,甚至越改越糟。这是典型的“自纠正过载”。
我的策略是给循环加硬停止条件,并且引入“环境熵”指标:每轮失败后,如果错误类型与上一轮差不多(比如一直是同一条 import 错误),就认为卡住了,直接进入人工标记。
还有一个实用技巧:每轮迭代后,保留上一次的完整文件快照。如果发现最新一轮的测试失败数量比上一轮更多,回滚快照,不再让代理继续走。这个“准回滚机制”帮我们避免了很多糟糕的自动提交。
4.5 一个很有用的补充排查:模型“撒谎”说测试通过了怎么办
有些模型会一本正经地回答“测试已通过”,但实际上根本没有执行工具。这是因为工具调用结果没有回灌给模型,它把“执行测试”和“测试通过”当成了推理中的一步。
Harness 必须保证工具执行结果真实存在于消息上下文中。我们的实现是:每一轮模型调用时,上一轮所有工具调用的真实 stdout、exit code 都作为“系统消息”放在请求里,模型无法伪造。只要做到这一点,“假装通过”的情况基本消失。
5. 扩展方向与我的体会
跑通这套最小 Harness 之后,我最大的感觉是:AI 编码代理的能力模型本身已经不是最主要瓶颈,工程约束和反馈设计才是决定产出可靠性的关键。就像一个实习生,只告诉他“去改代码”和给他一份明确的工作守则、一套代码规范、一个测试流程,产出质量是完全不同的。Harness 工程就是给这个“AI 实习生”配的工作守则、代码规范和测试流程。
往后有几个方向值得继续扩展:
- 引入多验证器议会机制,让单测、静态检查、类型检查、契约测试各自打分,最后综合裁决,避免某个验证器的单一失败误导代理。
- 把 Harness 接入 CI 流水线,让每次代理提交都能自动生成一份“代理行为报告”,人工只审核差异,而不是逐行读代码。
- 针对不同语言、不同框架沉淀专用工具集和反馈模板,把“能跑”提升到“符合团队规范”。
最后分享一个实际踩坑换来的经验:不要一开始就试图把 Harness 做得大而全。先给裸代理套上“禁止任意 shell”“跑测试验证”“最多改 5 个文件”这三条规则,你的代码可靠性就已经提升一大半。后面每加一条规则,都要通过回归对比来确认它确实减少了问题,而不是单纯增加代理的心理负担。好的管控脚手架,不是让代理寸步难行,而是让它每一次伸手都被看住、每一次落点都有回响。这套工程做完后,我再看“AI 生成代码”,关注点早就不在代码好坏上了,而在“它是在什么约束下生成的”——那才是可靠性的真正答案。