如果你最近在折腾 AI Agent,大概率绕不开一个词:Harness。我在整理 learn-claude-code 学习系列笔记时,第一个要啃的项目就是 Harness-0。这个名字看着有点唬人,但说白了,Harness 就是套在大模型外面的一层“控制装置”,让模型不再只是被动地聊天,而是能按照你设定好的流程去思考、调用工具、完成实际任务。这篇笔记我会把 Harness-0 的来龙去脉、核心代码、实操过程中踩过的坑全部记录下来,给同样在学 Claude Code、Agent 工程化或者“Harness 工程”的同学一份可以直接照着练的参考。
我假定看这篇文章的你,已经能写基础的 Python,能调用大模型 API,但对“怎么把模型接进一个完整的自动化流程”还比较模糊。这篇内容就正好补上这块缺口:不依赖任何重型框架,从零手写一个最小 Harness,搞清楚它内部到底发生了什么。整个项目大概一两百行代码,花一个下午跑通一轮,会比你看十篇概念文章都有用。
1. Harness 到底是什么:先把这个概念啃透
1.1 从“马具”到“大模型控制框架”
Harness 这个词,英文原意是马具、挽具,是控制马匹行动的那套装备。工程领域很早就在用这个词,比如测试领域的 test harness,指的是“控制被测对象运行并收集结果的一套装置”。你把马换成大模型,把缰绳和笼头换成代码逻辑,就得到今天要聊的东西:大模型 Harness,也叫 AI Agent 控制框架或模型工作流骨架。
它的核心职能是:接收用户任务,把任务转成模型能理解的对话消息,调用模型推理,再把模型输出中的意图(比如想调某个工具)解析出来并执行,然后把执行结果返回给模型,如此循环,直到任务完成或达到终止条件。换句话说,Harness 是连接“模型大脑”和“外部行动能力”的传动装置,没有它,模型再聪明也只能待在对话框里。
这也解释了为什么现在各种 Agent 产品、命令行工具(比如 Claude Code、Codex 这类交互式编码工具)不管外表多不一样,底层都长得很像——它们本质上都是一套 Harness,只是外层包了不同的交互界面、权限策略和工具集。
1.2 为什么学 Claude Code 要先搞懂 Harness
learn-claude-code 这个系列的学习起点放在 Harness 上,是有原因的。Claude Code 看起来是个命令行工具,但它真正厉害的地方,不是那层终端界面,而是内部那套控制 Claude 模型执行编码任务的 Harness:怎么维护多轮对话、怎么把读文件/写文件/执行命令这些能力暴露给模型、怎么在模型想跑测试时拦截并确认、怎么在输出太长时压缩消息…… 这些都是 Harness 工程要解决的问题。
如果你直接去看 Claude Code 的源码或者文档,很容易被庞大的模块数量劝退。反过来,先从 Harness-0 这种最小实现入手,亲手把循环、工具调用、上下文管理写一遍,再回去看那些生产级工具,你会发现它们只是在你的最小模型上做了大量加固和扩展。所以我特别建议,不要一上来就想着搭一个多复杂的 Agent 系统,先老老实实写一个最小 Harness,把地基打实,后面学什么都快。
1.3 Harness 与 Agent 的区别:别再傻傻分不清
很多人问“Harness 和 Agent 到底啥关系”,甚至有人把它们当成同义词。我自己的理解是:Agent 是目标,Harness 是实现目标的手段。Agent 描述的是“具备自主规划、调用工具、完成多步任务的智能体”这个产品形态;Harness 则是让你能稳定实现这个形态的工程骨架。
| 对比维度 | Agent(智能体) | Harness(控制框架) |
|---|---|---|
| 本质 | 产品/概念形态 | 工程实现组件 |
| 关注点 | 能自主规划、决策、行动 | 消息循环、工具调用、上下文管理 |
| 类比 | 一辆自动驾驶汽车 | 底盘、转向系统、传感器融合架构 |
| 回答的问题 | 它能做什么 | 它怎么稳定地做到 |
| 依存关系 | 依赖 Harness 来落地 | 可以独立存在,也能服务非 Agent 场景 |
我见过不少学习者在讨论时纠结“我这个算不算 Agent”,其实没必要。只要你用代码控制模型循环推理、按需执行工具,你就已经有一个 Agent 的雏形了,而这个雏形的载体就是 Harness。后面我会用代码把这件事彻底说清楚。
2. Harness-0 项目拆解:一个最小控制框架的完整设计
2.1 项目命名背后的学习路径
Harness-0 里的“0”有两层意思:一是“零号版本”,代表整个学习系列的第一个工程;二是“从零开始”,不带任何重型依赖。这个定位很重要,因为一旦引入 LangChain、LlamaIndex 这类框架,你会被封装好的高层接口掩盖掉底层细节,学完还是不知道原理。Harness-0 特意反着来,所有核心逻辑都自己写,框架只负责“提供模型”和“解析输入输出”。
从学习路径上看,我建议按这个顺序推进:先通读 Harness-0 的代码,搞清楚消息循环和工具注册;然后把代码复制一份自己跑通;接着尝试改功能(比如加一个新的自定义工具);最后再去看真实产品的实现。每一步的产出都是后面的素材,尤其是当你写到“给工具加权限确认”这种功能时,你会突然明白为什么生产级工具那么“啰嗦”。
2.2 最小 Harness 的核心组成
一个能正常完成任务的 Harness,再小也离不了下面这几块:
- 模型接入层:负责调用大模型 API,统一输入消息列表、输出模型回复。不同厂商的模型只要封装成同一个接口,就能无缝替换。
- 消息管理:维护整个对话历史。每一轮用户消息、助手消息、工具执行结果都要按顺序记录,这是模型理解的上下文基础。
- 工具系统:包括工具的注册表、工具的声明(名称、描述、参数格式)和工具的执行函数。模型通过声明的 JSON Schema 知道有哪些工具可用,代码负责在模型“决定调用”时真正执行。
- 运行循环:整个 Harness 的主循环。不断把消息发给模型、检查模型输出、执行工具、把结果追加进消息列表,循环往复。
- 终止控制:必须有明确退出条件,比如模型输出 final 回复、达到最大循环次数、用户主动中断。没有这块,模型很容易无限循环烧掉你的 API 额度。
这五个部分里,最核心的是运行循环,它是整个系统的引擎。工具系统是跟外部世界交互的手和脚,剩下几个是支撑它们的骨架和规则。
2.3 为什么先做“少”而不是“多”
我在最早设计 Harness-0 时,列过一堆自己想加的功能:流式输出、多模型切换、工具权限确认、记忆持久化、任务队列…… 最后全部砍掉,只保留最小闭环。原因是,任何复杂的系统,一旦跑不起来,你根本分不清是哪个环节出了问题。最小闭环能确保“改一行代码、看一次效果”的反馈循环足够短,这对学习阶段尤其重要。
而且,先做“少”能逼你直面本质。你会发现,所谓 Agent 的智能,很大一部分来自于“工具声明的质量”和“消息结构的清晰度”,而不是什么神秘算法。当你亲手把这两个环节调到能跑通时,很多网上抽象的说法(比如“提示工程很重要”)就会变成你身体记忆里的具体经验。
3. 从零实现 Harness-0:核心逻辑与关键代码
3.1 准备工作:核心依赖与版本约定
我用的环境是 Python 3.10+,只依赖一个 OpenAI 兼容的 SDK 用来调用模型。现在市面上绝大多数模型厂商都提供 OpenAI 兼容接口,所以用这个方式写出来的 Harness,切换模型时只需要改 base_url 和模型名,工具调用部分完全不用动。如果你只想跑通,也可以直接用一个支持 tool calling 的本地模型服务,具体环境上没有特殊要求。
代码层面,我建了一个 config.py 存放模型配置和 API Key,另一个 harness.py 放核心逻辑。先来看最简单的模型接入封装:
我这里不把完整代码全贴出来(避免篇幅过长),但会把最关键的三段逻辑讲透,因为它们就是整个 Harness 的心脏、双手和记忆。
3.2 消息循环:整个系统的心脏
假设你有一个函数call_model(messages),它接收消息列表并返回模型回复。主循环写起来非常短,但它是理解 Harness 的关键:
def run_harness(task: str, max_iterations: int = 10): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}, ] for step in range(max_iterations): response = call_model(messages) messages.append({ "role": "assistant", "content": response.get("content", ""), "tool_calls": response.get("tool_calls"), }) if not response.get("tool_calls"): # 没有工具调用,说明任务完成 return response.get("content") # 逐个执行工具调用,然后把结果写成 tool 消息 for call in response["tool_calls"]: result = execute_tool(call["function"]["name"], call["function"]["arguments"]) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": result, }) return "达到最大迭代次数,任务未完成"这段代码里有几个容易被忽略的细节。第一,assistant 消息里除了 content 还要带上 tool_calls,因为后续的 tool 结果消息必须绑定到某一次 tool_call_id 上,模型靠这个关联关系理解工具调用和结果的对应。第二,循环结束条件不是“模型说做完了”,而是“模型这一轮没有发起任何工具调用”,也就是说它决定直接回答用户了。第三,必须设 max_iterations 兜底,否则模型一旦陷入“调工具-看结果-再调工具”的循环,你的 API 账单会很难看。
我在第一次跑这个循环时,犯过一个低级错误:没有把工具结果转换成字符串前检查内容合法性,结果工具函数抛了异常,整个循环直接退出。后来我在 execute_tool 外面套了 try-except,把异常信息当作工具结果返回给模型,让模型自己决定下一步怎么办,这样体验好了很多。
3.3 工具注册与调用:让模型真正“动手”
工具系统有两面:一面是模型可见的“声明”,另一面是代码侧真正执行的“函数”。模型不会直接执行代码,它只是根据工具声明,发出“我想调用 compute_sum,参数是 [1,2,3]”的请求,真正去做加法的是你的代码。
我实现了一个极简注册机制:
TOOL_REGISTRY = {} def register_tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] = { "type": "function", "function": { "name": name, "description": description, "parameters": parameters, }, } func.__tool_schema__ = TOOL_REGISTRY[name] return func return decorator @register_tool( name="get_current_time", description="获取当前日期和时间,格式为 YYYY-MM-DD HH:MM:SS", parameters={ "type": "object", "properties": {}, }, ) def get_current_time(): from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M:%S")执行工具时,只需要从注册表里拿到函数对象,用 json.loads 解析模型传来的参数,再调用即可。这里必须特别注意:模型传参是 JSON 字符串,直接传给执行函数会报错,先解析再传参是基本功。另外,给工具写 description 的时候一定要具体,比如描述里写清返回格式、参数取值范围,模型才能知道什么时候该调用它,以及怎么填参数。
工具声明看起来只是给模型“看”的文本,但它的质量直接决定模型的工具选择准确度。我做过一个对比实验:把工具的 description 从一句话改成带示例的详细描述,工具调用准确率提升非常明显。这个经验价值很大,强烈建议你写工具时多花两分钟把描述写清楚。
3.4 上下文管理:控制窗口的隐形杀手
模型有上下文窗口限制,而每次工具执行结果都要写回消息列表,多轮下来很容易把窗口撑爆。Harness-0 里我实现了最简单的管理策略:当消息历史超过阈值时,丢弃最旧的对话轮次,只保留系统提示和最近几轮消息。
MAX_MESSAGES = 20 def trim_messages(messages): if len(messages) <= MAX_MESSAGES: return messages return [messages[0]] + messages[-(MAX_MESSAGES - 1):]这段代码很简单,但它背后涉及的是 Agent 工程里最经典的记忆权衡:保留太多历史,模型能记住更多上下文,但会占用窗口、增加耗时和成本;丢得太狠,模型会遗忘早期结论,甚至出现重复操作。最小 Harness 可以先一刀切,真实项目则要做更精细的摘要、压缩、关键信息抽取。
我在实际跑任务时,发现一个规律:上下文管理策略升级的优先级,远高于换一个更强的模型。因为很多模型“变笨”的案例,并不是模型退化了,而是消息列表太乱、太久远的信息稀释了注意力。建议你先记录每次循环后 messages 的实际长度,心里有个数,再去设计裁剪策略。
4. 实操过程记录:把 Harness-0 跑起来的完整流程
4.1 目录结构与依赖选择
我的项目目录非常朴素,方便任何人都能复制:
harness-0/ ├── config.py # 模型配置、API Key 读取 ├── harness.py # 核心 Harness 逻辑 ├── tools.py # 工具注册与实现 └── run.py # 入口脚本,读取任务并调用 harness依赖只装了两个:openai(用于调用兼容接口)和python-dotenv(用于管理环境变量)。如果你不想用 dotenv,直接用 os.environ 也可以。这个刻意精简的依赖选择,是为了让你在阅读和调试时,不会被无关第三方库干扰。当你跑通之后再引入 pydantic、rich 这类库,体验会更好。
4.2 逐步运行与核心日志观察
启动时我会在 run.py 里加一行打印,把每轮循环的关键事件输出出来,方便观察模型决策过程:
[step 1] 模型发起工具调用: get_current_time [step 2] 工具返回: 2025-06-20 15:30:11 [step 3] 模型发起工具调用: calculate_days_between [step 4] 工具返回: 3 [step 5] 模型无工具调用,输出最终答案这些日志看似简单,但它们把模型的“思考路径”完全暴露在眼前。当你发现结果不对时,看日志基本就能定位问题:是模型没理解任务?是工具描述不清楚?还是工具返回值格式有歧义?这种可观测性在小项目里是免费送的,在真实系统里却要专门搭链路追踪,所以学习阶段一定要养成看日志的习惯。
我在最初跑的时候,发现模型反复调用同一个工具但参数不变,查了半天日志才发现,是工具返回结果没有标记成 tool 角色,而是混在了 assistant 消息里,模型根本没有上下文可依赖。这个问题在代码上只是一行角色写错的差异,但不看日志根本想不到。
4.3 实测一个联动任务
我拿一个稍微复杂的任务来测试:让 Harness 计算“今天距离我设定的目标日期还有多少天”,并且要求它在回答之前先读取一个配置文件里的目标日期。这就迫使它必须至少调用两个工具:一个读文件、一个算日期。
实际运行中,模型先调用了read_file,拿到目标日期后,又调用calculate_days_between,最终输出自然语言答案。整个过程没有人为干预,全是模型基于工具声明自主决策完成的。这就是 Harness 魅力的直观体现:模型负责把自然语言任务拆解成工具调用序列,你的代码负责把每一步都稳稳落地。
这时候你应该能感觉到,所谓 Agent 的“自主性”,其实是在你精心设计好的轨道上完成的。Harness 的职责就是铺好轨道、设好红绿灯,让模型的聪明才智能在可控范围内释放。轨道铺得好不好,直接决定模型是顺畅到达终点,还是在原地打转。
5. 常见问题与排查技巧实录
5.1 依赖安装或服务启动卡住
很多人在搭建 Harness 或类似项目时,会遇到依赖安装卡住的情况。我自己在测试不同模型接入时,也碰到过前端依赖安装阶段长时间无响应的问题,例如日志长时间停在某个包的安装过程。
这类问题的排查思路是一致的:先确认网络能正常访问依赖源;再确认你使用的包管理器配置了可用源;最后确认版本之间没有冲突。切勿盲目换源或强杀进程,否则容易留下半装状态的依赖,后续更难排查。我个人的习惯是:先看完整日志,定位卡住的具体包名,再单独安装该包验证。同时,安装依赖时要区分“全局环境”和“虚拟环境”,建议全程使用 Python 的 venv,避免污染系统环境。
提示:任何安装卡住的问题,第一步永远是“看日志定位卡在哪”,而不是“重装一遍试试”。盲目重装大概率浪费更多时间。
另外,如果你在本地起了一个模型服务,但要先下载模型权重,这一步也很费时。我的经验是,学习 Harness 阶段没必要追求跑本地大模型,直接用 API 更高效。先把闭环跑通,把概念理解了,再回头优化部署细节。
5.2 模型反复调用同一工具或陷入死循环
这是 Agent 项目里最经典的问题。原因通常有几类:工具返回结果没有携带足够信息,模型不知道“这次调用已经生效”;工具描述有歧义,模型反复用不同的参数尝试;上下文被裁剪后,模型失去已经执行过某步骤的记忆,又开始重来。
我的排查顺序是,先看完整日志,确认模型到底忽略了哪个信号;然后检查工具返回值,看它是否清晰表明“已完成/失败/错误原因”;最后检查上下文裁剪策略,看是不是把关键中间结果给剪掉了。实在不行,就把最大迭代次数调低,至少保证不烧太多钱,再来优化交互逻辑。
5.3 本地部署时资源占用过高
如果你最终还是要本地部署模型服务,会遇到内存和 CPU 飙高的现象。原因是模型权重加载进内存本身就占资源,再加上并发请求全部在 CPU 上跑推理,速度会非常慢。我踩过几次坑之后,总结出几个做法:优先考虑量化版本的模型文件,把精度从 fp32 降到 8bit 或 4bit;启动服务时限制最大并发数;尽可能用 GPU 推理而不是纯 CPU。
提示:学习阶段的关键指标不是“推理多快”,而是“能否正确跑通一个任务”。哪怕慢一点,只要能跑通并打印出完整日志,就比盲目优化性能更有价值。
6. 学习实践中的一些个人体会
说点掏心窝的话。我见过很多人学 Agent 工程,一上来就铺开各种框架,最后被抽象层困住,遇到问题根本不知道从哪入手。Harness-0 这种“从零写起”的训练方式,其实是在帮你建立对系统的直觉:你知道模型会在哪一步可能出问题,知道消息结构变化会带来什么连锁反应,知道一个工具描述写得烂会导致整个任务崩掉。这些直觉,靠读文档是攒不出来的,必须亲手跑崩溃几次才有。
我现在的做法是,每学一个新概念,都试着把它“塞”进 Harness-0 里。比如想学记忆管理,就给它加一层向量检索;想学权限控制,就给它加一个工具调用确认机制。这个最小框架成了我的实验田,几乎任何 Agent 相关的想法都能它上面快速验证。强烈推荐你也保留一个这样的小项目,别急着写多复杂,能承载你持续学习的“最小实验田”就足够了。