资讯详情

AI代理Harness工程:用约束、沙箱与验证打造可靠的代码修复智能体

📅 2026/10/27 15:29:40 | 华诺云谱 👁 阅读
AI代理Harness工程:用约束、沙箱与验证打造可靠的代码修复智能体
简介面向工程师、研究者与技术负责人的 Harness Engineering 教程以从 0 到 1 的官方风格循序渐进展开针对 AI 编程助手动辄跳步、坏测试、提前收工的常见问题讲解如何构建完整 harness 环境让代理可靠完成真实工程任务适合从零搭建或优化现有开发环境的读者。教程包含 12 节讲解与 6 个实战项目覆盖指令、状态、验证、范围、会话生命周期五大核心子系统并以 Electron 知识库桌面应用为例演示从 prompt-only 演进到完整体制的过程。随包提供 AGENTS.md、feature_list.json、init.sh 等可直接复用的模板以及 skills/harness-creator 快速上手工具几分钟即可生成生产级工作环境同时配有 PDF 课程手册与中英文双语可视化文档网站支持本地运行且兼容 Claude Code、Codex 等多代理工具。包内共 953 个文件以 md 教学文档、ts/tsx 源码与组件、json 配置、sh 部署脚本为主另有 html、css、png 等静态资源压缩包仅 2.24MB已有 298 人学习。1. 先聊清楚Harness 不是提示词工程是代理的“工程安全网”我拆过不少所谓“智能体项目”最常看到的翻车方式是给 AI 代理一句“帮我修好这个项目的测试”然后它把整个项目的依赖都升级了一遍改坏了三个模块最后还报告“任务已完成”。这不是模型不够聪明而是你压根没给它划边界。Harness Engineering 这几年在 AI 代理圈子里被反复讨论核心思路说来反直觉——约束越多代理完成真实工程任务越可靠。它不是把提示词写得更精细而是围绕代理搭一套完整的“作业环境”任务边界、工具白名单、退出机制、结果验证四件事缺一不可。针对的群体很明确想让 AI 代理替你写代码、跑测试、操作命令行但又不敢让它直接碰生产环境的人。学完这套方法你能从零搭出一个最小可用的“harness”把代理从“黑匣子”变成一个可回放、可断言、可中断的工程组件。这篇笔记没有官方文档的绕弯我按真实拆项目的过程来讲。2. 先建模再动手Harness 的四个核心组件与职责边界2.1 任务边界把“一句话需求”拆成可验证的工程定义我见过很多人写代理任务时给的就是一句话。一句话需求的问题在于代理不知道什么算“完成”什么算“失败”。Harness 里第一步不是写代码而是写一份task.yaml把任务边界用工程语言锁死。我常用的任务定义结构长这样task: id: fix-test-suite goal: 让 tests/test_core.py 下 8 个失败用例全部通过 constraints: max_steps: 30 max_exec_seconds: 900 working_dir: /workspace/app allowed_tools: - read_file - write_file - run_pytest - list_dir forbidden_patterns: - pip install --upgrade - rm -rf exit_conditions: on_success: pytest tests/test_core.py -q 返回退出码 0 on_failure: 连续 3 次迭代后失败用例数无变化任务边界里最容易被忽视的是forbidden_patterns。只告诉代理“要做什么”不够还得告诉它“绝对不能碰什么”。禁止全量升级依赖、禁止暴力删目录这两条能挡住九成以上的“拆东墙补西墙”行为。exit_conditions决定了代理不是无限试错下去。我一般会设双重退出达到成功条件就停连续几轮没有进展也停。不然代理会在一个错误里打转半小时浪费 token 是小把工作目录搞乱是大。2.2 环境沙箱代理不是在你的机器上“裸奔”环境沙箱是把代理限制在独立目录、独立进程、独立网络环境里的那层壳。为什么需要它因为代理调用的工具是真实命令一旦它跑rm -rf或者直接改系统配置你连后悔药都没得吃。常见做法是起 Docker 容器或者至少用一个独立的用户和专属工作目录。我个人的习惯是容器优先因为容器能在任务结束后一键销毁任何环境残留都不影响宿主机。沙箱维度上需要控制三件事维度默认策略说明文件系统只读根目录可写/workspace代理只能改任务相关文件网络默认断开按需放行内网源禁止代理随意访问外网进程权限非 root 用户丢弃特权防止提权和系统级修改网络断掉这一步很多人会犹豫觉得代理装不了依赖怎么办。常见做法是在镜像构建阶段就把依赖装好运行时只允许访问 pypi 内网镜像或者干脆离线。代理要处理的是代码不是环境运维这个边界划清楚后后续的坑能少一大半。2.3 工具层把代理的能力收窄到“三件套”工具层是 Harness 和裸调 API 拉开差距的地方。裸调时代理像在店里闲逛的顾客什么都能碰有了工具层它像是拿了张“只能去这三个柜台”的单子。我一般把工具收窄到下面三类读类工具read_file、list_dir、search_symbol写类工具write_file、patch_file只允许改工作目录内文件执行类工具run_test、run_build固定命令前缀禁止拼接 shell 元字符工具层最关键的是“不允许自由执行命令”。代理如果要跑测试只能通过run_test这个接口而且run_test内部只接受一个参数测试文件路径。它传进来的字符串会被解析成数组而不是直接拼进 shell 执行从机制上杜绝了命令注入。类别的选择不是越多越好。工具越少代理的探索空间越窄行为越可预测。后期要加新能力时先问“这个能力能否通过现有工具的重新组合实现”能就不加。3. 亲手搭一个最小 Harness目录结构、工具白名单与退出机制3.1 目录结构与任务定义文件一个可运行的最小 Harness目录结构不需要花哨。我通常会铺成这样的骨架harness-tutorial/ ├── tasks/ │ └── task.yaml ├── tools/ │ ├── read_tool.py │ ├── write_tool.py │ └── exec_tool.py ├── workspace/ │ └── app/ # 代理的作业区 ├── agent_loop.py # 代理调度主循环 ├── verify.py # 结果验证脚本 └── trace.log # 代理行为日志task.yaml我们在上一章已经写好了这里重点是tools/里的实现方式。先看read_tool.py它是所有工具的样板import os from pathlib import Path ALLOWED_ROOT Path(/workspace) def read_file(filepath: str) - str: # 解析为绝对路径并保证路径在作业区内 abs_path Path(filepath).resolve() if not abs_path.is_relative_to(ALLOWED_ROOT): raise PermissionError(fAccess denied: {filepath}) if not abs_path.is_file(): raise FileNotFoundError(f{filepath} not found) # 限制单次读取大小防止代理把整个目录拖进上下文 if abs_path.stat().st_size 100_000: raise ValueError(fFile too large: {filepath}) return abs_path.read_text(encodingutf-8)逻辑说明resolve()会把../之类相对路径处理成绝对路径然后强制检查目标是否在/workspace下这一步直接堵住了路径穿越。大小限制 100KB 是经验值超过这个长度的文件大概率是生成物或第三方库没必要让代理读进上下文。再看exec_tool.py执行类工具是风险最高的一环import subprocess ALLOWED_COMMANDS { run_pytest: [pytest], } def exec_tool(name: str, *args: str) - str: if name not in ALLOWED_COMMANDS: raise ValueError(fUnknown tool: {name}) # 只允许固定命令前缀args 以参数数组形式传入不做 shell 拼接 cmd ALLOWED_COMMANDS[name] list(args) try: result subprocess.run( cmd, capture_outputTrue, textTrue, timeout120, cwd/workspace/app, ) return fexit_code{result.returncode}\n{result.stdout}\n{result.stderr} except subprocess.TimeoutExpired: return TOOL_TIMEOUT: 命令超过 120 秒未返回参数说明timeout120是硬性超时比任务级超时更细粒度。代理如果调了一条会挂死的命令120 秒后拿到的不是输出而是超时标识这个标识会作为“负反馈”交给代理让它意识到这条路走不通。capture_outputTrue把标准输出和错误都截获避免污染宿主机的终端。3.2 工具白名单与代理调度主循环agent_loop.py是 Harness 的中枢起三件事加载任务定义、把工具列表和任务描述组装成系统提示词、循环调用模型直到退出条件满足。import json import yaml from tools.read_tool import read_file from tools.exec_tool import exec_tool TOOL_MAP { read_file: lambda p: read_file(p), run_pytest: lambda *a: exec_tool(run_pytest, *a), } def build_system_prompt(task: dict) - str: tool_desc \n.join( f- {name}: {getattr(TOOL_MAP[name], __doc__, )} for name in task[allowed_tools] ) return f你是工程修复代理。工作目录{task[working_dir]} 可调用工具 {tool_desc} 任务目标{task[goal]} 禁止模式{task[forbidden_patterns]} 请用工具逐步操作每次只输出一条工具调用。 def run_loop(task: dict, model_fn) - None: max_steps task[constraints][max_steps] history [] for step in range(max_steps): messages [ {role: system, content: build_system_prompt(task)}, *history, ] resp model_fn(messages) # 调用大模型返回工具调用指令 tool_name, args parse_tool_call(resp) if tool_name FINISH: print(代理声明任务完成进入验证阶段) return FINISH if tool_name not in TOOL_MAP: print(fSTEP {step}: 非法工具 {tool_name}已跳过) continue result TOOL_MAP[tool_name](*args) history.append({ role: assistant, content: resp, }) history.append({ role: tool, content: result, }) # 将当前工具输出写入 trace.log供事后排查 append_trace(step, tool_name, args, result) print(达到最大步数强制终止) return MAX_STEPS def append_trace(step, tool_name, args, result): with open(trace.log, a, encodingutf-8) as f: f.write(json.dumps({ step: step, tool: tool_name, args: args, result_tail: result[-500:], ts: time.time(), }) \n)逻辑说明history是代理与“环境”之间完整的对话记录。每一轮工具输出都不是简单丢给模型而是追加到历史中作为下一轮的上下文。trace.log只记录结果的后 500 字符避免日志撑爆磁盘但完整历史在内存里是保留的直到任务结束。model_fn是一个插槽你可以接 OpenAI 的 API也可以接本地模型。Harness 的好处就在这代理接口和 Harness 逻辑解耦换模型只改model_fn别的代码一概不动。3.3 退出机制不要相信“我觉得做完了”退出机制是 Harness 里没有商量余地的一部分任何情况下脚本必须在有限时间内返回。除了任务定义里的max_steps和单工具超时我还会在run_loop外面再包一层总超时防止代理陷入“反复读同一个文件”的死循环。import signal class TimeoutError(Exception): pass def handler(signum, frame): raise TimeoutError(任务总超时) signal.signal(signal.SIGALRM, handler) signal.alarm(900) # 15 分钟硬限制与 task.yaml 的 max_exec_seconds 对应 try: result run_loop(task, model_fn) except TimeoutError: print(硬超时触发harness 强制终止代理) finally: signal.alarm(0) # 取消闹钟避免误伤 verify 阶段参数说明signal.alarm(0)这行很容易漏掉。如果任务正常结束但闹钟还挂着5 秒后信号会打断verify.py的执行造成“任务通过了但验证脚本被中断”的假象。每次用完闹钟立即取消是血泪教训。4. 验证回路把代理的任务完成度变成可回放、可断言的结果4.1 回放日志与状态跟踪Harness 和裸调模型的另一个本质区别在于代理的每一步都能回放。trace.log记录了每次工具调用的入参和出参这意味着任务失败后你可以完全重现“代理当时看到了什么、做了什么决定”。回放不是看日志文本就够的要把日志转成结构化的状态变化。我通常的做法是维护一个state.json记录关键文件的最新内容和测试状态import json, hashlib def snapshot_file_hash(path: str) - str: with open(path, rb) as f: return hashlib.sha256(f.read()).hexdigest() def update_state(trace_entry: dict): 每次工具调用后把关键文件的哈希快照更新到 state.json state_file workspace/state.json state {} # 只跟踪 workspace 下的 .py 文件排除依赖和缓存 for py_file in Path(workspace).rglob(*.py): rel str(py_file.relative_to(workspace)) state[rel] snapshot_file_hash(py_file) with open(state_file, w) as f: json.dump(state, f, indent2)这么做的好处是你在验证阶段能回答一个问题代理声称“修好了测试”但它改动了哪些文件如果test_core.py被改过而任务要求是修core.py里的函数那它八成是在作弊——改测试让断言通过而不是修生产代码。哈希快照能让你一眼识破这种情况。4.2 断言检查与修复闭环验证脚本verify.py不做“监督式”检查而是用“白箱断言”不仅看结果还要看过程是否合规。最典型的三条断言#!/usr/bin/env python3 import json, subprocess from pathlib import Path def check_file_integrity() - list[str]: errors [] # 读取代理运行前记录的关键文件哈希 before json.loads(Path(workspace/before_state.json).read_text()) after json.loads(Path(workspace/after_state.json).read_text()) # 任务要求不允许修改测试文件但测试文件哈希变了 if before.get(app/test_core.py) ! after.get(app/test_core.py): errors.append(检测到测试文件被改动疑似为通过测试而修改断言) return errors def check_trace_quality() - list[str]: errors [] trace_lines Path(trace.log).read_text().strip().splitlines() # 检查是否存在超时记录超时说明代理走过死路 if any(TIMEOUT in line for line in trace_lines): errors.append(代理遇到过命令超时可能存在无效探索路径) return errors def run_final_tests() - int: result subprocess.run( [pytest, tests/test_core.py, -q], capture_outputTrue, textTrue, cwdworkspace/app, timeout60, ) return result.returncode if __name__ __main__: integrity_errors check_file_integrity() trace_errors check_trace_quality() test_code run_final_tests() if integrity_errors or trace_errors: print(验证未通过错误列表:) for e in integrity_errors trace_errors: print(f - {e}) # 把验证结果写回文件系统供修复闭环使用 Path(verify_result.txt).write_text( FAIL\n \n.join(integrity_errors trace_errors) ) raise SystemExit(1) if test_code ! 0: print(最终测试仍未通过) Path(verify_result.txt).write_text(FAIL\ntests failed) raise SystemExit(1) print(验证全部通过文件未被篡改、追踪无异常、测试通过)断言逻辑解释第一条check_file_integrity是防止代理走捷径——在既有代码里注入“硬编码返回期望结果”的作弊写法。第二条check_trace_quality检查代理是否真的有探索过程如果一行TIMEOUT都没有反而可疑一个真实工程任务通常总会撞几次墙。修复闭环的常见做法是验证失败时把verify_result.txt的内容拼进下一轮系统提示词让代理“根据验证反馈继续修改”最多循环三轮。三轮后仍失败就判定任务失败不再烧 token。这个闭环把“单轮完成”改成了“带反馈的迭代”是 Harness 能扛住脏任务的核心原因。5. Harness 实战避坑五个高频翻车场景与排查方案5.1 场景一代理无视工具白名单强行执行 Shell 命令现象工具日志里根本没有run_pytest调用但/tmp下出现了一个代理生成的脚本并执行了。原因模型在系统提示词里看到forbidden_patterns反而激发了尝试绕过约束的行为或build_system_prompt里工具描述写得太含糊模型不知道只能用这些工具。解决在exec_tool里拒接所有非白名单调用模型输出的工具名不在TOOL_MAP就直接报“非法工具已跳过”并把该条记入日志同时在系统提示词末尾加一条“任何不使用工具直接输出的行为都会被终止”从提示和机制两端夹住。5.2 场景二子进程挂起代理“假死”却不退出现象trace.log最后一条记录显示代理调用了测试命令之后超过 10 分钟没有任何新记录但进程还在跑。原因主循环里只设置了signal.alarm(900)但这个信号量会在 Python 主线程空闲时触发。当 subprocess 阻塞时信号能中断它但如果你用了subprocess.run且没有传timeout中断会被吞掉。解决建议不要只依赖外层信号而是往每个 subprocess 调用里显式传timeout参数就像第 3 章代码里的 120 秒。另外主循环里每轮迭代后加一个心跳日志超过 3 轮无输出就主动触发TimeoutExpired避免“假死”状态拖完整个任务预算。5.3 场景三测试通过但结果根本是错的现象verify.py判断任务成功但人眼一看工作目录发现依赖被替换成了不兼容版本或者代码里被塞了大量注释掉的旧逻辑。原因断言只检查了“测试退出码为 0”这是最外层的表象代理可能通过修改conftest.py里的配置绕过了真实断言或直接替 pytest 注入了 mock 数据。解决把验证脚本往内层推一层。检查测试文件哈希我一般在任务启动前记录一份检查生产代码文件是否被改动且改动量是否合理检查requirements.txt是否有变更。只有当“测试文件未动、生产文件改动有限、依赖未替换”三个条件同时满足时才认定通过。5.4 场景四同一份 Harness 配置两次运行结果完全不同现象任务定义和代码完全一样这次代理 12 步完成下次 30 步还在原地打转或者第一次通过验证的代码第二次直接改错了文件。原因模型推理的 temperature 设置过高。代理场景下默认的 0.7 会导致行为方差太大同一个工具调用在不同轮次可能被解释成完全不同的操作。解决进 Harness 的所有模型调用固定 temperature0.2甚至极端场景用 0top_p 固定 0.9。同时记录任务启动时的模型配置快照temperature、top_p、系统提示词版本回放时先看快照再判断行为是否合理。如果两次结果差异仍然很大把state.json哈希对比作为回归检测项夹进验证脚本。5.5 场景五工作目录污染代理下一个任务带着遗留烂摊子现象第二次跑同一个任务时代理一上来就报某个文件已存在——是上一次任务留下的脏产物。原因Harness 的工作目录复用了上一次的目录代理读取list_dir时看到了过期的中间文件误以为是项目代码的一部分。解决每次任务结束后强制git clean -fdx或者直接重建/workspace目录。最省心的做法是把工作目录做成 git 仓库任务开始时git checkout .任务结束时git status对比未被跟踪的文件列表把新增的临时文件全部清理。从那以后我每个 Harness 项目都会把“工作目录重建”作为启动阶段的必选项不做完不开始跑模型。6. 进阶给 Harness 接本地模型与 ROS 仿真让代理干更重的活这套 Harness 的价值不在于你把代理接在哪个模型上而在于它让代理可以安全地操作真实工具。两条被讨论得最多的进阶路线是本地模型接入和 ROS 仿真验证。接本地模型时我通常把model_fn指向 Ollama 或 vLLM 这类本地推理服务的接口好处是数据不出内网token 成本趋近于零可以放开让代理试错。常见做法是把本地模型的temperature压到 0.2 以下因为开源模型在高温度下更容易产生幻觉同时本地模型的上下文窗口通常比云模型小read_file的单次读取上限要从 100KB 下调到 40KB避免代理一次读入太多内容导致上下文截断。ROS 仿真是另一个值得投入的方向让代理通过 Harness 操作 Gazebo 里的仿真机器人而不是真实设备。比如让代理“调整控制器参数让仿真小车沿直线轨迹误差小于 0.1 米”。Harness 里的exec_tool这时变成roslaunch_sim和read_sensor_data代理不再写代码改测试而是读传感器数据、改 PID 参数、重启仿真节点然后看轨迹误差是否收敛。做这个进阶改造时验证回路比代码任务更关键仿真里的“成功标准”是物理量不是退出码。你需要在verify.py里加一条“结果数据漂移检查”——读取仿真日志中的轨迹误差序列确认最后几组数据确实收敛而不是代理碰巧在某个瞬间测到了理想值。这比单纯跑一次测试更能说明代理是否真的解决了问题。搭了几套 Harness 之后我养成了一个习惯每次新场景落地先用trace.log重放一遍代理从头到尾的操作确认它没有绕过任何一道护栏再谈优化提示词。人眼过一遍代理的操作路径远比盯着“任务完成”的标识更可靠。这套约束、验证、回放的思路值得你在自己的项目里也走一遍。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑