从零手写一个 mini-harness——看懂 agent 会干活的底层

从零手写一个 mini-harness——看懂 agent 会干活的底层

1、引言

你每天在 Claude Code 里敲一句"帮我把这个 bug 修了",它就自己读文件、改代码、跑测试、再改,直到绿。这中间到底发生了什么?把它包成"会自己干活"的那层东西,叫harness。这篇就用最近从零手写的练手项目My-Agent(Go + DeepSeek,刻意不套任何 SDK)把这层东西拆开:它由哪几个零件组成、那个让它"转起来"的循环长什么样、怎么一步步从"能跑"长成"好用、可演进到企业级"。

先把窗户纸一句捅破:大模型本身只是个无状态的文本函数——喂它一段文本,它吐一段文本,仅此而已;它不记事、不动手、不会自己干第二步。所谓 agent 能自己读文件、记得住你、连着干十几步,没有一行是模型的"魔法",全是你在它外面用普通代码搭的脚手架(harness)。看懂这层脚手架,agent 的神秘感就消失了——这也是全文要带你看到的那个"原来如此"。

全文用一个具体任务当主线,从头演示到尾:

你对它说:"我叫张三,帮我读一下项目根目录的 README,看完告诉我这项目是干嘛的,顺便记住我是谁。"

后面每讲一个概念,都回到张三这句话,看它在这一层是怎么被处理的。

全文路线—— 先看裸模型缺什么、harness 怎么把缺的补上,再拆零件、钻循环、做到好用,最后聊怎么上手、往后能长成什么。

你是重度 AI 编码工具用户,Claude Code、Cursor 闭着眼用。可有人问你一句"它底层到底是 agent 还是 harness?那个循环谁在控制?模型怎么就会自己读文件了?"——你大概率卡壳。不是不聪明,是这层底座平时被工具糊得严严实实,你从没机会看它一眼。

更别扭的是另一条路:想搞懂就去"套壳"——拿现成的 agent 框架配一配。可那等于换了个壳子用别人的 harness,该不懂的还是不懂,你看到的依然是封装好的 API,不是"裸大模型怎么变成会干活的 agent"。

所以这次反着来:不套壳,从零手写。用几十行 Go 把那个循环、工具、记忆全摊开,亲手让一个"只会说话"的大模型变成"会自己读文件、记得住你是谁"的 agent。骨架和 Claude Code 同源,工程量差着十万八千里,但够你彻底看懂它为什么这么设计

要看懂"加了什么",得先看清"原本缺什么"——下面这张图就是裸大模型 API 和一个会干活的 agent 的差距。

裸大模型 API 只会文本进文本出;harness 在外面补上循环、工具、记忆,才成了会自己干活的 agent

2、harness 到底给大脑补了什么

痛点:你直接调一次 DeepSeek 的/chat/completions,把张三那句话发过去,会拿到什么?一段文本——"好的张三,但我看不到你的文件,请把 README 内容贴给我"。就这么一次,结束了。它不记事(下次再问"我是谁"它一脸茫然)、不会动手(碰不到你的文件和网络)、不会循环(给完一段话就停,不会"读完再想下一步")。裸 API 就是一个只会说话、被锁在玻璃房里的大脑

注意上面那三个"不"——不记事、不会动手、不会循环。harness 干的事,就是逐个把它们补上:配一本随时翻阅的记事本(记忆),接一双能真正读文件/跑命令的(工具),套一个让它"想一步、做一步、再想"的循环。开头那句"模型只会说话、其余全是外面搭的脚手架",落到实处就是这三件。Claude Code、Cursor 本质都是把这三样做厚、做稳。

举个具体例子:张三那句话进了 harness,会变成这样一段交互——大脑说"我要用read_file工具,路径./README.md"(它只是开口点单),harness 真的去磁盘把文件读出来、塞回给大脑,大脑这才接着说"这项目是一个 Go agent harness……";同时 harness 把"用户叫张三"记进了记事本。模型负责动脑,harness 负责动手和记事——这就是分工。

实际用途:理解了这层,你就能回答开头那个卡壳的问题了,也能自己造一个——给任何一个大模型接上你自己的工具(查数据库、调内部 API),让它替你干活。

harness = 给"只会说话的大脑"接上手(工具)、循环(会想下一步)、记事本(记忆)三样东西

顺带厘清 agent 和 harness:宏观上"agent"指这个会自主干活的整体,"harness"指支撑它的那层脚手架——日常说"Claude Code 是个 agent",落到代码上你写的就是它的 harness。本文写的 My-Agent,既是一个 agent,也是一个 mini-harness。

3、一个最小 harness 的五个零件

痛点:"脚手架"听着虚,到底要写哪些代码?很多人卡在这——以为要一上来搭个庞大框架,结果还没开始就放弃了。其实最小可跑版就五个零件,加起来几十行。

把张三的任务跑通,需要这五样,各司其职:

零件干什么在 My-Agent 里
会话状态一个消息列表(system / user / assistant / 工具结果),每轮往里追加harness/types.goMessage
模型客户端把消息 + 工具定义 POST 给 DeepSeek,拿回一条回复harness/client.go,裸net/http
工具注册表每个工具 = 定义(名字/参数 schema)+ 真正执行它的 Go 函数harness/tools.go
循环harness 的灵魂:调模型→有工具就执行→塞回→再调,直到给最终答案harness/agent.go
记忆按 session 存取历史,跨轮(甚至跨重启)记得住harness/memory.go+memstore/

举个具体例子:工具注册表里的一个工具长这样——就是"给模型看的说明书"加"真正干活的函数"两半:

// read_file:告诉模型"有这么个工具、要一个 path 参数", // 以及 harness 真正执行它时跑的 Go 函数。 Tool{ Def: FunctionDef{ Name: "read_file", Description: "Read a UTF-8 text file and return its content.", Parameters: /* JSON Schema: {path: string} */, }, Run: func(args json.RawMessage) (string, error) { // 解析出 path,os.ReadFile 读出来返回 }, }

实际用途:看懂这五个零件的边界,你就知道"加一个能力"该往哪儿塞——加工具改注册表、换记忆改 memstore、调提示词改设置,核心循环一行不用动。这正是它能慢慢长大的根。

最小 harness 的五个零件:会话状态、模型客户端、工具注册表、循环、记忆

4、那个循环,就是 agent 的"心跳"

这是整个 harness 的灵魂,也是最该看懂的一段。看懂了这十几行,你就看懂了 Claude Code 的本质——剩下的全是工程量(更多工具、更稳的记忆、更细的权限),不是新原理。

痛点:大模型一次调用只能"说一段话",可张三的任务明明需要好几步——先读文件、再理解、再回答。单次调用怎么变成"读完再想下一步"的多步干活?答案不在模型里,在外面那个循环

它是什么:一个朴素到家的for循环,每轮干这么几件事:

loop: ① 调模型(带上所有工具定义) ② 模型没要工具 → 这就是最终答案,返回 ③ 模型要调工具 → 逐个真执行(harness 的"手") ④ 把每个工具结果按 role:"tool" 塞回上下文 ⑤ 带着新结果再循环,让模型决定下一步 (兜底:撞到 MaxSteps 就停,防跑飞)

举个具体例子(张三的任务在这个循环里走了两圈):

  • 第 1 圈:模型看到"读 README 并总结"+ 可用工具列表,回了一句"我要调read_file,path=./README.md"(finish_reason=tool_calls)。循环走到 ③,harness 真的去读了文件,把内容按role:"tool"塞回消息列表。
  • 第 2 圈:带着 README 全文再调一次模型,这回它不要工具了,直接给出"这项目是一个从零手写的 Go agent harness……"——② 命中,返回最终答案。

两圈,一次工具调用,任务完成。模型全程碰不到你的文件,它只会"开口点单";真正动手的永远是 harness。

回到开头那句话——agent 会"连着干好几步"这件看似神奇的事,本质就是这个for循环多转了几圈,没有别的魔法。

那个循环:调模型→要工具就执行→结果塞回→再调,直到最终答案;撞 MaxSteps 兜底

这里藏着一个最容易被忽略、却最关键的分工:tool use 不是"模型自己执行了工具",而是模型要调哪个工具、给什么参数,harness替它执行、再把结果喂回去。模型是点菜的客人,harness 是后厨。想明白这条,你就不会再问"模型怎么会读我文件"——它不会,是 harness 读的。

tool use 的分工:模型只"开口点单"(说要调哪个工具),harness 才是真正动手执行的那只手

5、从"能跑"到"好用",还要补几件事

五个零件能跑通张三的任务了,但离"好用、企业级"还差得远。My-Agent 接着补了几样,每一样都对着一个真实痛点。它们共享同一条设计原则——先把这条总纲说透,后面的可插拔记忆、分层记忆、权限、agent team 就都顺了。

一条贯穿始终的设计原则

痛点:项目要慢慢做大,最怕的就是"加个新功能得动核心代码"——改一次,处处回归。比如记忆,今天用本地文件,明天想换 Postgres,要是写死在循环里,换一次伤筋动骨。

原则:凡是"有、或将来可能有多种实现"的能力,一律抽成一个接口、给多个实现、用配置里一个带默认值的开关选用哪个——绝不写死一种再加兜底。加能力 = 加一个实现 + 一个选项,核心不动。下面这张图就是这条原则的样子,后面记忆、执行后端全是它的实例。

第一设计原则:同一种能力 = 一个接口 + 多个实现 + 一个配置开关,加能力不动核心

可插拔记忆,换后端不动核心

痛点:记忆存哪,不同人需求天差地别——你只想试跑,纯内存就行;想持久又不想装数据库,落本地文件最省事;团队上生产,得用 Postgres。要是框架替你写死一种,总有人不爽。

怎么做:记忆就是一个三方法的Memory接口(Append/Load/Reset),底下挂五种实现:filewiki(本地文件,默认)、postgresredisinmemnone。启动时一个backend开关选哪个,首次启动还会问你一句"记忆存哪",而不是默默替你拍板。

举个例子:张三那句"记住我是谁",在 filewiki 后端下就落成本地一个.jsonl文件,一行一条消息;换成 Postgres,就变成一张表里的几行——循环和工具完全不知道、也不关心记忆存哪,因为它们只认Memory接口。

分层记忆,让它记得前天和昨天

痛点:光把原始对话堆着,有两个毛病——越攒越大撑爆上下文窗口;而且"前天聊过的事"埋在几千条消息里,它根本翻不到。你周一告诉它"我在做 My-Agent",周三再聊它早忘了。

怎么做:在后端之上再套一层"分层记忆"。对话按天归档,每天对话结束滚动出一份≤2000 字的当天摘要;下次对话时,按新近度权重把近 30 天的每日摘要 + 当天原始对话拼进上下文。默认保留 30 天,超期自动清理。短期(当天原始)和长期(每日摘要)共用同一个 Memory 接口,跟用哪个后端无关。

举个例子:周一张三聊了一下午 My-Agent,当晚这段被总结成一句"用户张三,在做 My-Agent 的 Go 项目,偏好简洁代码"存进digest;周三他开口问"我上次说在忙啥来着?",harness 把这条摘要(连同更早几天的)按时间近的优先拼回去,它就答得出来——像它一直记得

分层记忆:对话按天归档→每天滚动出≤2000字摘要→按新近度权重召回近30天摘要+当天原始

操作权限,别让它真把你的 .ssh 读出来

痛点:你给了它read_file工具,它就能读任何文件。万一模型"自作主张"(或被一段恶意提示带跑),来一句"我读一下~/.ssh/id_rsa看看"——你的私钥就被读出来塞进上下文了。能力越大,越得有闸。

怎么做:工具执行前过一道权限闸。每个工具标个敏感度(read/write/exec),配置里给每类一个动作:allow(放行)/ask(执行前问一次)/deny(禁止)。另外不管策略怎么配,read_file对像密钥的路径(.ssh*.pemconfig.local.json……)一律硬拒

举个例子:张三的任务里read_file ./README.md是普通读,放行;可一旦路径变成~/.ssh/id_rsa,直接被硬拒,返回一句"refused: looks like a secret file"喂回模型,任务继续但私钥纹丝不动。

agent team,主 agent 派小弟各司其职

痛点:一个复杂任务塞给单个 agent,它容易"既要又要"顾头不顾尾;有些子任务还希望换个专门的人设来干(代码审查 vs 写诗),甚至想把不可信的活关进隔离环境跑。

怎么做:加一个spawn_subagent(role, task)工具,主 agent 能把子任务派给一个角色化的子 agent,自己拿回结果再继续。子 agent 在哪儿跑由Runner接口决定——local(本进程,默认)或docker(每个子 agent 起一个一次性容器隔离),一个mode开关切换。这又是上面那条设计原则的实例。

举个例子:你让主 agent"派个数据库专家解释下索引",它就spawn_subagent("数据库专家", "一句话解释数据库索引");local模式下子 agent 在本进程跑完把答案带回,docker模式下它在一个容器里跑、结果从 stdout 带回——主 agent 全程只管派活和收结果。

agent team:主 agent 用 spawn_subagent 派角色化子 agent,Runner 决定它在本进程还是容器里跑

6、推荐学习路线,照着梯子爬

别想着一口吃成 Claude Code。按这个顺序,每一阶都能跑通、有正反馈:

  1. 先把那个循环跑起来(最重要的一步)。一个模型客户端 + 一个for循环 + 一个最简单的工具(比如返回当前时间的now),让模型学会"点单→你执行→塞回→再问"。看懂这 50 行,你就过了最大的坎。
  2. 加一个真正动手的工具:read_file。体会"模型只点单、harness 才动手"的分工,顺便撞上"要不要让它随便读文件"这个安全问题。
  3. 加跨轮记忆:先用一个内存里的map实现Memory接口,让它记得住张三;再换成落本地文件,体会"接口不变、实现可换"。
  4. 加配置与权限:把系统提示词、模型名抽到配置文件,密钥单独放;给工具加上allow/ask/deny的闸。
  5. 再上分层记忆、子 agent、容器隔离:这些都是在前四阶的接口上"加实现",不碰核心。

推荐学习路线:循环→加工具→加记忆→加配置与权限→分层记忆/子 agent,一阶一个可跑的正反馈

7、后续的扩展方向

My-Agent 还远没完工,但好处是——下面每一条都是"加一个实现 + 一个开关",核心不用动。按三个方向拆:

接更多模型和工具

  • 模型 provider 可插拔—— 今天是 DeepSeek,抽一个Provider接口就能挂上别的模型,一个开关切换。
  • MCP 接入—— 实现 MCP 客户端,自动发现并注册外部工具,瞬间接上一大票现成能力。

把交互做顺滑

  • 流式输出—— 让答案一个字一个字往外蹦,而不是憋完才给。
  • 上下文压缩—— 长对话快撑爆窗口时,自动把早期内容摘要替换,聊一整天不断片。

更聪明也更可控

  • 语义记忆(RAG)—— 给记忆加一个 pgvector / 向量库实现,做到"按意思召回"而不只是按时间。
  • 可观测—— 每一步的 token、成本、耗时都记下来,方便排查和省钱。

后续扩展方向:模型 provider、流式、上下文压缩、语义记忆、MCP、可观测——每条都是"加实现+开关"

8、回到开头,你换来了什么

文章开头那个让你卡壳的问题——"它底层到底是 agent 还是 harness?那个循环谁在控制?模型怎么就会自己读文件了?"——读到这儿,你应该能一句句答上来了。这就是亲手写一遍的价值,三件实打实的东西:

  • 看懂了底座:你不再把 Claude Code 当黑盒。那个循环、tool use 的分工、记忆怎么跨轮——全是你亲手写过的;再被问"它底层是什么",你能画给对方看。
  • 能改、能扩、不怕大:因为"接口 + 多实现 + 开关"这条原则,换记忆、加工具、接新模型都是"加一个实现 + 一个选项",核心稳如老狗。
  • 一个企业级雏形:配置/密钥分离、本地可视化后台、权限闸、子 agent 隔离——这些不是玩具特性,是真往生产方向铺的地基。

自己写一遍换来的三样东西:看懂底座、能改能扩不怕大、一个企业级雏形

说到底,大模型那一下"文本进文本出"始终没变;变魔术的从来不是模型,是你在外面那几十行循环、工具、记忆。自己把这层脚手架写一遍,你对"AI 怎么变成会干活的 agent"的理解,会从"听说过"变成"我造过"。这中间的差距,值得你花一个周末。