ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

从零构建Agent Harness:拆解Claude Code的AI编程助手

从零构建Agent Harness:拆解Claude Code的AI编程助手 1. 先把“AI 编程助手”拆成一块块看得见的零件最近不少人问我既然 Claude Code 已经这么强为什么还要花时间去“从零构建一个 Agent Harness”我的回答是把一个黑盒拆开再重装一遍你对它的掌控会完全不同。文章标题写着“Learn Claude Code”但我想说的比单纯学命令用法更深一层把 AI 编程助手当成一个由好几块零件拼起来的系统然后亲手写出一个最小可运行的 harness。Claude Code 本质上并不“神秘”。当你坐在终端前敲下一句“帮我把这个模块的重试逻辑修一下”它背后发生的其实是一连串非常工程化的步骤先收集仓库上下文再把任务和上下文一起交给模型模型输出一个行动意图外围框架负责执行这个行动然后把执行结果再喂回模型如此循环直到模型认为任务完成。真正决定体验高下的不是模型本身有多聪明而是这一圈循环做得是否稳定、收敛、安全。这篇文章适合两类人一是已经在用 Claude Code但想知道“它到底是怎么做到的”的开发者二是想自己搭一套内部 AI 编程工具却不太清楚该从哪里动手的人。我会按我实际搭建玩具项目的顺序来讲先讲最小闭环再逐步补上权限、反馈、上下文管理这些零件。读完之后你能自己写出一个能读文件、改文件、跑测试、然后根据测试结果自我修复的 agent 循环。1.1 把 Claude Code 的使用体验拆成四个组件我自己的拆解习惯是把一个 AI 编程助手分成四块任务注入用户指令加上仓库结构、相关文件内容被组装成一段上下文。决策循环模型根据上下文决定下一步动作可能是读文件、写文件、跑命令也可能是直接给出最终结论。工具集合一系列可供模型调用的函数比如读文件、搜索文件、执行 shell 命令。安全与反馈执行前做权限判断执行后把退出码、标准输出等结果返回给模型。这四块里面最容易被忽视的是第四块。很多人以为只要把模型接到终端上它自然就能“编程”了其实不是。模型只是一个“思考者”一个行动要被真正执行需要经过工具层和权限层的翻译与约束。Claude Code 之所以比普通聊天机器人更适合编程就在于它的第四个组件做得足够细致它会自动跑测试会把错误日志截短后再喂给模型也会在模型要执行危险命令时停下来问用户。1.2 Agent Harness 到底是个什么东西Harness 在中文里可以叫“套具”或“脚手架”但我更愿意叫它“夹层逻辑”。它夹在模型和操作系统之间负责把模型的文本输出翻译成可执行动作再将动作结果序列化回上下文。举个例子模型告诉你“我想读取 config.yaml”但这只是它输出的一句话。Harness 要做的事情是从这句话里解析出工具名“read_file”、参数“config.yaml”找到对应的函数把文件内容读出来然后以标准格式写回模型的历史消息。如果工具调用格式不对harness 还得负责返回一个错误让模型看到之后重新生成。这就是为什么我说模型不是主角流程才是主角。模型的输出千变万化一个合格的 harness 要做的就是把这些千变万化收敛成可执行的、可控的步骤。你可以把 harness 想象成一位严谨的车间主任模型是技术顾问。技术顾问可以提出各种方案但最后拧螺丝、通电、质检的步骤都必须经过车间主任的流程和审批。车间主任可以没有顾问那么懂技术但他必须保证每个动作都留下记录、不会越界、也不会在同一个错误上反复折腾。1.3 为什么“从零构建”比“读文档”更有学习价值直接读 Claude Code 的文档你只会知道它能做什么比如“可以编辑文件”“可以并行运行多个任务”。但你自己动手写一个最少 200 行左右的 harness 之后才会理解它为什么那样设计。举个我在搭玩具项目时反复遇到的例子模型偶尔会不按约定输出结构化的工具调用而是直接说一段话比如“我先读取一下文件再决定怎么改”。如果 harness 不处理这种情况整个循环就断了。好的做法是把自然语言和结构化调用都解析一遍先尝试严格解析失败就退回模糊提取再失败就把“当前输出无法理解”明确告诉模型。类似这样的细节不亲手写一遍代码是体会不到的。所以我后面几个章节的内容都是从一个能跑的玩具 harness 出发一步一步加零件。每一步我都会解释“为什么需要这个部件”因为这才是从零构建的真正价值不只是知道怎么拼而是知道为什么这么拼。2. 最小版 Harness 的核心一个能自己跑命令的循环先别急着加权限、加测试、加上下文压缩。一个 agent 的最小形态只需要一件事一个能反复“想、动、看”的循环。第一版我建议把目标定得极小——给模型一个任务让它能读取当前目录里的文件列表最多循环 6 轮然后就停止。能跑通这个再谈其他。2.1 先约定一个可替换的模型后端入口为了不把注意力浪费在具体厂商 API 上我在代码里统一用一个call_backend()函数作为模型入口。后面无论是接 Claude 系列还是其他模型都只需要替换这一个函数。这个函数接收历史消息和工具定义返回一个结构化结果要么是“最终回答”要么是“工具调用请求”。SYSTEM_PROMPT 你是一个运行在终端里的 AI 编程助手。 你可以调用下列工具来完成任务 - read_file(path): 读取文件内容 - list_files(path): 列出目录下的文件 - run_command(command): 在项目目录下执行命令 当任务完成时直接输出最终结论不要附带任何工具调用。 TOOLS_DEFINITION [ { name: read_file, description: 读取指定文本文件的内容, parameters: {path: string}, }, { name: list_files, description: 列出指定目录下的条目, parameters: {path: string}, }, { name: run_command, description: 执行一条 shell 命令, parameters: {command: string}, }, ] def call_backend(messages, tools): # 真实实现中这里会调用你选择的模型接口。 # 返回值示例 # {type: tool_call, tool_name: list_files, tool_args: {path: .}} # {type: final, content: 我已经完成了任务。} raise NotImplementedError我把call_backend留成未实现原因很简单不同读者手上的模型入口不一样。有人用一个几十行的本地模型有人用厂商 API还有人想用命令行封装。把模型调用单独隔离出来之后整条 agent 循环的逻辑就可以完全复用。2.2 最小循环解析动作、执行、回填下面是整个 harness 的核心我认为 8 行就能讲清楚import json def run_agent(task: str, max_steps: int 6): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: task}, ] for step in range(max_steps): response call_backend(messages, TOOLS_DEFINITION) if response[type] final: print(f[agent] {response[content]}) return response[content] action parse_action(response) result dispatch(action) messages.append({ role: tool, tool_name: action[tool_name], content: json.dumps(result), })这个循环里最关键的两个函数是parse_action和dispatch。parse_action负责把模型的响应解析成一个标准动作结构比如{tool_name: read_file, tool_args: {path: config.yaml}}。dispatch根据动作名找到对应的工具函数并执行同时负责捕获异常、记录执行时间、把执行结果封装成字符串。这里有一个初学者很容易漏掉的点工具执行的结果一定要以“工具消息”的形式继续追加到messages里而不是单独存放在一个变量里。因为下一轮模型调用时它必须能同时看到自己刚才做了什么以及得到了什么结果才能决定下一步动作。如果你只是把结果存在内存里下一轮请求就会缺一段关键历史模型就会“失忆”。2.3 第一版跑通后我遇到的问题第一次跑通这个循环我挺兴奋的但紧接着就撞上两个问题。第一个问题是解析容错。模型经常不老老实实返回结构化字段而是输出类似“让我先看看当前目录下有什么”这样的自然语言。如果直接把这段扔给dispatch必然报错。我的解决办法是让parse_action做三层尝试先按严格 JSON 解析失败则尝试从文本里用正则提取工具名和参数再失败就把整段文本作为一条“模型消息”返回给模型请它重新组织输出。这么做的理由是很多本地模型的输出格式稳定性确实不如大厂 API但 harness 可以把这点差异消化掉。第二个问题是工作目录混乱。我一开始让run_command在进程默认目录下运行结果发现同一个命令在不同时间执行跑的位置不一样模型拿到的结果自然也不一致。统一做法是给所有工具函数传入一个固定的workspace路径常量所有相对路径都基于这个常量解析。这在后面接入真实项目时特别重要一个 agent 如果连“我在哪个目录里”都不能保证那它读到的文件和执行测试的结果都会很不可靠。3. 给 Harness 补上工具注册表与权限边界最小循环跑通之后下一步不是继续加功能而是把工具层做规范。因为当模型能调用工具之后你就把一部分系统控制权交给了模型输出这必须有“围栏”。3.1 用注册表而不是一堆 if-else 管理工具早期版本里dispatch函数用一长串 if-else 来分发动作每加一个工具就要改一次函数。后来我发现用注册表设计能省掉大量重复代码也给后续的权限校验、参数校验留出了统一入口。TOOL_REGISTRY { read_file: { fn: read_file, validate: lambda args: isinstance(args.get(path), str), permission: read, }, list_files: { fn: list_files, validate: lambda args: isinstance(args.get(path), str), permission: read, }, run_command: { fn: run_command, validate: lambda args: isinstance(args.get(command), str), permission: write, }, } def dispatch(action): try: tool TOOL_REGISTRY[action[tool_name]] except KeyError: return {ok: False, error: f未知工具: {action[tool_name]}} args action.get(tool_args, {}) if not tool[validate](args): return {ok: False, error: f参数类型错误: {args}} try: result tool[fn](**args) return {ok: True, data: result} except Exception as e: return {ok: False, error: f{type(e).__name__}: {e}}注册表的好处是新增工具时只需要往字典里加一个条目同时因为所有工具都经过dispatch统一处理异常、校验、权限检查都可以在这一层集中做。模型调用工具时万一拿到一个错误结果也不会让整个 harness 崩溃而是把错误作为下一轮输入的一部分让它自己想办法修正。这在 agent 场景里非常有用。3.2 别急着给模型一把无限权力的 shell很多第一次写 harness 的人会直接给模型一个run_command允许它执行任意 shell 命令。我也这么干过结果不是模型跑飞而是我自己吓出一身冷汗——它竟然尝试去删除一个看似无关的缓存目录。我后来把工具集合收得很紧第一版只有三个工具read_file、list_files、run_tests。其中run_tests只允许执行固定的测试命令不接受任意参数。想跑 grep 之类的搜索再加一个只读的search_files。这样虽然没有给模型最大的自由度但保证了它在这个阶段能完成绝大多数任务。只有当它明确遇到现有工具覆盖不了的情况我才会考虑放开一个更通用的命令工具而且一定要配上下面的权限检查。3.3 用 allowlist 和用户确认把危险操作拦下来如果要提供一个通用run_command就必须有权限策略。我的做法是分层防错词法层检查命令头部策略层拦截危险模式。import shlex ALLOWED_COMMAND_HEADS { python, pytest, ls, cat, find, grep, sed, awk, diff, wc } BLOCKED_PATTERNS [ rm -rf, mkfs, dd if, :(){, curl.*|sh, /dev/sd, chmod -R 777 ] def approve_command(raw_command: str) - bool: parts shlex.split(raw_command) if not parts: return False if parts[0] not in ALLOWED_COMMAND_HEADS: return False return not any(pattern in raw_command for pattern in BLOCKED_PATTERNS)这段代码不是绝对安全但它足够作为第一道防线。真正执行前我还会再加一道“人工确认”的关卡当模型要执行命令时harness 先把命令打印出来等待用户按y后才执行。Claude Code 在实际使用中也有类似交互——在关键操作前停下来要用户确认。这道关卡尤其在模型要执行写操作、删除操作或者管道命令时不能省。4. 还要定义“任务成功”测试、反馈与有限循环一个人工智能编程助手和一个聊天机器人的最大区别是它有“客观反馈”。模型改完代码后凭什么说它改对了凭测试结果。没有测试作为判据模型就会在一轮轮自我感觉良好中瞎改。所以这一章要给 harness 装上“眼睛”和“刹车”。4.1 用测试退出码作为模型的“好消息”和“坏消息”我的系统提示里专门有一段话当你修改完代码之后立刻调用run_tests工具如果测试失败仔细阅读最后 30 行错误输出定位原因后继续修改最多尝试三轮如果三轮后仍然失败停止修改并给出你的判断。代码层面run_tests工具会返回三个关键信息退出码、标准输出的最后若干行、标准错误的最后若干行。这三个信息会以 JSON 字符串的形式进入下一轮上下文。退出码是 0模型就知道任务完成了退出码非 0模型就知道还得继续干活。关键是错误信息必须截短否则一次失败的测试可能有几百行输出直接把上下文撑爆。我后面会专门讲上下文管理这里先记住“只回传 tail 而非全量”这个原则。4.2 防呆设计识别“重复动作”和“循环卡死”就算有了测试反馈模型也可能陷入一个经典陷阱它在同一个错误上反复尝试同样的修改只是每次说法略有不同。这种循环在真实项目中太常见了尤其是模型在上下文较长、不能完全记得前一轮动作的时候。我在 harness 里加了一个“最近动作去重”机制from collections import deque def detect_repetition(recent_actions: deque, threshold: int 3) - bool: if len(recent_actions) threshold: return False return len(set(recent_actions)) 1 recent deque(maxlen3) # 每轮循环中 if detect_repetition(recent): messages.append({ role: tool, name: system_note, content: 检测到连续重复动作请换一种策略。 }) recent.clear()这个机制的作用不是阻止模型尝试而是强制它跳出惯性。重复三次同样的动作基本可以判定当前策略无效与其让它继续浪费 token不如直接把“换个思路”作为明确指令注入到上下文里。类似机制还可以扩展成“同一条命令连续失败两次后暂时冻结”比如 pytest 连续两次失败就先把pytest从 allowlist 里临时移除一轮逼模型先检查代码而不是再次盲跑测试。4.3 词法黑名单与系统提示双保险很多人会问既然我已经在系统提示里写了“不要删除文件、不要访问生产环境”是不是就够了不够。模型偶尔会忽略指令或者被后续较长的上下文干扰忘记最初的安全约定。所以系统提示是策略层allowlist 是执行层两层必须同时存在。我在系统提示里会写你只能操作当前项目工作区内的文件。禁止删除未说明的文件禁止修改.env、密钥文件、数据库文件。在执行任何可能造成不可逆影响的命令前必须先用describe_plan输出执行计划。而在执行层即使模型真的请求了类似rm -rf的命令approve_command也会直接拒绝。对 agent 来说安全永远不能只依赖模型的“自觉”而是必须由 harness 兜底。这是我的一个比较坚定的经验宁可让模型因为工具受限而多问几句也不能让它拥有越过围栏的能力。5. 从玩具到能用的编码助手上下文管理与增量编辑到这一步你的 toy harness 已经能完成“读代码、改文件、跑测试、反复修复”的闭环了。但一旦放到真实项目中很快会遇到一个成长瓶颈上下文不够用。真实仓库的文件数量和测试输出会让模型在几轮之后就“忘掉前面在做什么”。这章讲我实际动手时踩坑最多的地方。5.1 先给工具输出上一层“止血带”有一次我让 harness 跑一个稍微大一点的测试套件结果单轮工具输出就带回了 4000 多行内容。下一轮请求直接报上下文超限。从那以后我给自己定了一条硬规矩任何工具返回内容写入上下文之前必须经过裁剪器。def clamp_text(text: str, max_lines: int 200, max_chars: int 6000) - str: lines text.splitlines() if len(lines) max_lines: lines lines[-max_lines:] return \n.join(lines) f\n[已截断仅显示末尾 {max_lines} 行] if len(text) max_chars: return text[-max_chars:] f\n[已截断超过 {max_chars} 字符] return text这个裁剪器很简单但它改变了整个 harness 的可靠性。对成功的测试模型其实只需要看“PASS”“退出码 0”这几个字对失败的测试它需要的是末尾的错误摘要和 traceback 尾部。开头几百行通常是无用的环境信息全部保留只会稀释模型的注意力。5.2 大文件修改用 apply_patch 而不是整个文件重写早期我让模型改代码用的是write_file工具直接把整个新文件内容写给 harness。文件小的时候还好文件一旦超过一千行问题就来了模型很可能会在重写过程中丢掉函数或者不小心把某段注释改动掉。而且整文件重写也极度浪费 token——目标只是改一行字段名却要把整个 800 行的文件重新发一遍。更好的方案是引入apply_patch工具让模型输出一个 unified diff由 harness 在本地应用这个 diff。这样模型只需要给出改动区域上下文消耗可能降到原来的十分之一。即便 diff 格式有问题冲突信息也可以转换成错误返回给模型让模型重新生成更小的补丁。我在系统提示里放了一个简短示例告诉模型“下面是一个非常常见的 diff 格式请严格按照这个格式输出”。Claude Code 内部其实也采用了类似的编辑策略尽量做增量编辑而不是整文件覆盖既省 token 又降低出错概率。5.3 历史压缩当 agent 开始“忘事”的时候即使做了工具输出裁剪多轮之后的历史仍然可能膨胀。我碰到的典型情况是前两轮模型读了三个文件第三轮跑测试第四轮改代码第五轮又跑测试。到第六轮时前面读过的文件内容其实已经被后续动作替换了但一直留在历史里。此时如果不做处理token 预算很快就爆。我后来加了一个简单的“压缩策略”每完成一个明确的阶段性动作比如改完一个文件、跑完一轮测试就把最旧的一轮工具结果压缩成一行摘要用类似[步骤 1-2] 读取了 config.py发现 retry 相关代码在 45-60 行的句子替换原始内容。这个压缩动作发生在 harness 层模型完全感知不到但它看到的历史变得更紧凑。还有更激进的办法当历史长度超过预设阈值直接让模型自己写一段“到目前为止完成情况和下一步计划”然后用这段摘要替换掉先前全部对话。6. 复盘与进阶拿 Claude Code 当尺子衡量我的 Harness把 toy harness 搭到这一步它在功能上已经很接近一个小型 AI 编程助手了。但真正让这套东西变得有价值的是自我对照。我会拿 Claude Code 的实际表现当尺子一条一条对照看哪些我做对了哪些我可以直接模仿哪些未来值得继续做深。6.1 功能差距对照表以下是我在某个阶段做的对照表。注意这不是为了“追平”而是为了看清楚每个功能模块组合出来的体验差异。维度我的 toy harnessClaude Code 的成熟表现我的结论循环控制单线程、固定上限支持多线程任务、长时间运行、可中断核心循环思路一致但并发和控制信号做得更深工具数量4 到 5 个读、写、编辑、搜索、终端、代码查询等数量不是关键覆盖场景才是权限模型allowlist 用户确认会话级权限、项目级白名单、可配置策略我的机制可用但粒度不够细上下文管理裁剪器 历史压缩自动压缩、子对话隔离、分步任务记忆方向正确工程化程度差很多可观测性JSONL 日志内部有 trace 可视化我的日志方案更进一步成为调试核心这张表不是给产品做 benchmark而是用来指导下一步开发。比如我发现“子对话隔离”这个能力很重要的时候就去查 Claude Code 在这一块的设计思路然后回到自己的 harness 里实现一个粗版本把不同子任务的历史分开存放。这个过程非常爽因为你不是在抄答案而是在理解为什么需要这个答案。6.2 日志回放是我调试 Agent 循环的救命稻草单独说一下可观测性。没有日志你根本没法判断一个 agent 为什么行为异常。我记得有一次模型总是不执行工具而是光说“我准备做这件事”排查了很久最后发现是系统提示里“工具调用格式”的示例写得不够强让模型产生了误解。如果不是把每一轮的完整请求和响应都记录成 JSONL这个问题很可能会被我归因到模型能力上而不是 harness 的提示设计上。我采用的日志格式很简单每个事件一行 JSON包含时间戳、步骤序号、消息角色、工具名、参数摘要、执行结果 exit code、耗时、消耗 token 数。排查时用grep过滤某个 step再按时间顺序一口气回放就能还原模型当时看到的一切。调试 agent 时最重要的认知就是把模型当作一个需要完整上下文才能运转的系统你能看到多少上下文你就能定位多少问题。6.3 再往前走多 Agent、沙箱与流式输出玩具项目到这里已经可以收尾了但如果你想把这条路继续走下去有几个方向我认为特别值得探索。多 Agent 是最自然的一个扩展。把“规划”和“执行”分开一个 Agent 只负责分析任务、拆解步骤、生成方案另一个 Agent 只负责做具体文件修改和命令执行。这样做的好处是规划 Agent 的上下文不会被工具输出污染执行 Agent 也不需要读过所有说明文档。子代理之间的通信接口需要固定我用的是 JSON 格式的“任务单结果单”本质上又成了一层小 harness。沙箱是第二个方向。让模型在容器或临时目录里执行命令可以极大减少误操作带来的风险。虽然做容器封装会有额外性能开销但如果你准备把这个 harness 用在不可完全信任的输入场景沙箱不是可选项而是必选项。流式输出则是体验层面的提升把模型的思考过程和工具调用结果实时推到终端而不是等整个任务跑完才一次性显示。真正常用的 AI 编程助手都会这么做因为它让你感觉自己在“看着别人干活”而不是在黑盒等待。实现思路也不复杂只需要把call_backend换成支持流式回调的版本把事件分发到终端渲染层。说句实在话我第一次跑通这个玩具 harness 的时候最大的收获不是“我会写 agent 了”而是我终于能解释清楚很多现象——为什么模型会反复改同一个错误为什么强行塞进太多上下文会让它变笨为什么权限机制必须放在工具层而不是提示词层。拆过一次再装回去再看 Claude Code 就觉得它不再是黑盒而是一套我深刻理解工作原理、能预测它行为边界的工具箱。如果你也在学习这类 AI 编程助手我的建议很简单不必一开始就追求完整功能先写一个能跑通一个真实小任务的循环然后慢慢地往里面加测试反馈、权限确认和上下文压缩。每一步都值得。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表