从零搭建 Agent Harness 系列(六)上下文工程
从本系列的博客一开始,我们就讲过,LLM就是新的OS的CPU,传统CPU的输入和输出是电信号,进行的运算是布尔运算,那么LLM的输入和输出是token,进行的运算是token预测。
为什么传统软件比Agent的输出给人的感觉更可靠,除了布尔运算可靠以外,布尔运算的输入也是绝对按照程序员的意志来的,是从高级的逻辑语言一步步严格逻辑等价的转换成电信号这种低级的逻辑语言的,故而每次布尔运算的输入无论是信息的相关性和准确性都是完全按照人的意志来的,如果有问题,一定是人出错了,debug就行了。
而如果要Agent也给人这种可靠感,作为软件开发者,正如我们没办法改动CPU的布尔运算逻辑一样,我们也没办法改动LLM的token生成逻辑,我们能做的就是提高LLM输入的相关性和准确性。整个Agent Harness核心宗旨也是这个:为当前的任务目标提供相关且准确的输入,这个就是上下文工程。
既然说到LLM是CPU,Agent就是操作系统,那么Prompt就是CPU之上的软件,软件有很多种类型,比如系统软件,应用软件。但无论什么软件,它的运行首先要加载到内存中,内存十分珍贵,要做到动态的加载必要的代码进入内存。同样的,LLM的上下文窗口就是它的内存,我动态拼接正确的prompt进入上下文窗口。
认知重塑:Prompt 不是字符串,而是“操作系统内核”
在传统的开发思维里,Prompt 往往被视为发给 API 的一个文本常量。但在工业级 Harness 驾驭工程中,System Prompt 被视为大模型运行时的操作系统内核(Kernel),它必须是模块化“编译”和“动态链接”的。
如果当前的运行目录(Workspace)不是一个 Git 仓库,为什么要把长达 500 Token 的“Git 提交流程规范”塞给大模型?如果用户只是问今天的天气,为什么要把项目的微服务架构图告诉它?
冗长的无关信息不仅白白消耗高昂的 API Token 费用,更会严重稀释大模型的注意力,导致它在真正关键的指令上发生幻觉。
顶级引擎(如 OpenClaw)给出了一个极其优雅的分层加载策略:
极简内核(Minimal Core):引擎代码里只硬编码最基础的身份认知、交互模式,通常不到 1000 Tokens。
工作区守则(AGENTS.md):状态外部化。引擎会去读取用户工作区根目录下的 AGENTS.md 文件。这个文件由人类维护,声明当前项目的专属架构和规范。
技能外挂(Skills):特定领域的知识包(SOP)。它们以独立的目录和文件形式存在,按需提供给智能体。

揭秘 Agent Skills 规范:让大模型掌握专业 SOP
在上面的架构中,AGENTS.md 解决的是“当前项目是什么样”的问题,而 Skills(技能)解决的则是“特定任务该怎么做”的问题。
过去,开发者喜欢随便写个 Markdown 文件扔给大模型。但随着驾驭工程的发展,业界逐渐沉淀出了一套开放、轻量级的标准规范,例如 Anthropic 推出的开放规范 Agent Skills (agentskills.io)。
这套规范的核心理念是:将一项技能封装为一个独立的文件夹,并通过 SKILL.md 结合 YAML 前言(Frontmatter)进行标准化描述。
一个标准的 Skill 目录结构如下:
1 | my-skill/ |
其中最核心的是 SKILL.md 文件。它必须以 YAML Frontmatter 开头,定义技能的 name(名称)和 description(何时使用该技能),随后才是具体的 Markdown 指令正文。
为什么需要这种规范?
它完美契合了驾驭工程中**“渐进式暴露(Progressive Disclosure)”**的上下文管理哲学:
在引擎启动时(Discovery 阶段),Harness 可以只解析 YAML 头部,将 name 和 description 告诉大模型。只有当大模型明确判定当前任务需要该技能时,再去加载完整的 Markdown 正文(Activation 阶段)。这极大地节省了 Context 内存!
Session 隔离与 Working Memory
试想一下:群 A 的人在让 Agent 重构 main.go,而群 B 的人同时让 Agent 查服务器日志。如果我们只有一个全局的 contextHistory 切片,这两个完全不相干的任务指令和文件内容就会混杂在一起发给大模型。大模型瞬间就会精神分裂。
我们需要攻克上下文工程体系(Context Engineering)中的第二个核心痛点:多端并发场景下的 Session(会话)物理隔离。
多端并发下的 Session 物理隔离
在底层架构上,Session 的本质是一块被隔离的上下文内存空间。
我们必须引入一个全局的 SessionManager。当请求到来时,Manager 根据请求的来源(如终端目录哈希、飞书 ChatID、微信 OpenID)分配或唤醒对应的 Session 实例。每个 Session 实例内部维护自己的历史消息队列,并通过 sync.RWMutex(读写锁)保证并发安全。
注:在成熟的引擎如 Claude Code 中,Session 的历史记录通常会以.json或.jsonl的格式持久化落盘到工作区的隐藏目录中,以支持重启恢复。为了保持本专栏初期的极简,我们今天先在内存中实现这套隔离机制,并预留后续持久化的设计空间。
Working Memory(短期工作记忆)的边界
假设我们成功实现了 Session 隔离。用户 A 在群里和 Agent 聊了整整一个下午,来回发了 50 条消息。如果在用户 A 发第 51 条消息时,我们将这 50 条庞大的历史记录全部塞进 contextHistory 发给大模型,会发生什么?
严重超时的响应、天价的 Token 账单、甚至大模型 API 拒绝服务(400 Bad Request)。
认知科学告诉我们,人类在解决当前问题时,大脑中活跃的仅仅是“短期工作记忆”。大模型同样如此。它不需要记住你两个小时前问过的无关痛痒的问题,它只需要记住你们最近讨论的上下文。
因此,在顶级的 Harness 工程中,系统会维护一个长期的 Session 历史池,但在真正向大模型发起推理(Generate)时,系统只会截取最近 N 轮对话作为 Working Memory,再结合 System Prompt 拼装出当次的请求。

在这套架构下,无论用户和 Agent 聊了多久,发送给大模型的 Context 大小始终是被严格控制在 Working Memory 边界内的。这极大地保护了系统的稳定性。
在业界顶级 Harness 引擎的真实生产级做法中,提取 Working Memory 通常会结合以下更精细的策略:- Token 感知截断(Token-aware Truncation):系统不会简单按“条数”截取,而是实时计算每条历史消息的 Token 数量(通过 BPE 词表)。它会从后往前塞入消息,直到总 Token 逼近模型安全水位线(比如 120k Tokens)时才停止。- 摘要接力(Episodic Summarization):这是一种极其高级的玩法。当历史记录被截断时,引擎会在后台触发一个小的廉价模型,将“被抛弃的远古历史”浓缩成一段百字左右的大纲(Summary),并将其塞入 System Prompt 的头部。这样,大模型既拥有了最新的细节记忆,又保留了远古的宏观记忆。
突破内存:基于阶梯降级的 Context Compaction 策略
Agent 终于摆脱了“单次运行就失忆”的尴尬,能够像人类一样,在长程对话中保持上下文的连贯性,并且通过只截取最近的 N 条消息,有效地控制了日常闲聊的 Token 消耗,并保证了短期工作记忆的聚焦。
但是,作为一个以代码重构和系统运维为己任的通用型工业级 Coding Agent,它的核心动作不仅仅是聊天,而是执行工具(Tools)
设想这样一个场景:你让 Agent 去排查一个线上故障。它在第 2 个回合(Turn 2)调用了 read_file 工具,读取了一个长达两万行的 Nginx 报错日志(约 1MB)。
即使你的 Working Memory 保护区设置得再小(比如只保留最近的 3 条消息),只要这其中一条消息(即 read_file 的执行结果 ToolResult)包含了这 1MB 的超长文本,大模型 API 依然会瞬间抛出一个冰冷的错误:400 Bad Request: context length exceeded
在驾驭工程中,有一条不可动摇的铁律:如果大模型是 CPU,那么 Context Window(上下文窗口)就是极其昂贵且容量受限的 RAM(内存)。物理防御(防止内存溢出 OOM)的优先级,永远高于业务逻辑(短期记忆的完整性)。
为什么不能简单粗暴地清空长历史?
遇到上下文超限,很多新手开发者的第一反应是:“如果历史消息太长,我直接把字数超过阈值的那个消息从数组里删掉不就好了?”
绝对不行。我们复习一下大模型的 ReAct (Reason + Act) 循环。模型解决复杂问题,依赖的是连贯的长程逻辑链(Chain of Thought, CoT)。
如果你直接把前面的“工具调用结果”整条删了,就会出现一个致命的上下文断层:大模型在历史中明明发出了一个 bash ‘cat large.log’ 的 ToolCall,但在上下文中却找不到任何对应的 ToolResult 回复。
大模型会陷入极度的困惑,它可能会以为自己刚才的命令没发出去,于是再次发起 bash ‘cat large.log’ 的请求,从而陷入原地打转的死循环。
因此,在驾驭工程中,处理内存压力必须采用“阶梯降级(Staged Degradation)”策略。我们的目标是:丢弃冗余的数据(释放物理内存),但死死保住意图和逻辑链。
一种解法:Observation Masking 与 Head-Tail Truncation
我们可以将需要压缩的上下文消息,根据其在对话中的“距离”,施加不同级别的“降级魔法”:
System Prompt(系统提示):永远保留,神圣不可侵犯。
远期历史:超出 Working Memory 保护区的早期对话。在这里,大模型的 ToolCall(调用了什么工具、传了什么参数)必须保留以维持逻辑链,但是工具执行的返回结果(往往几千字)将被彻底掩码替换(Masking),比如变成一句话:“…[为了节省内存,早期的工具输出已被系统清理。原始长度: 15000 字节]…”。
Working Memory(短期工作记忆):最近的N轮对话。我们期望它是完整的。但如果其中单条工具输出实在太长(比如超过了 1000 字符),哪怕它处于保护区内,我们也必须触发掐头去尾截断法(Head-Tail Truncation),仅保留前 500 字和后 500 字。因为对于报错日志来说,开头说明了错因,结尾通常带有堆栈总结,中间的无尽循环完全可以抛弃。

记忆沉淀:状态外部化,基于文件系统的持久化记忆与待办管理
我们通过构建 Context Compactor(上下文压缩器),成功为 go-tiny-claw 装上了一个强健的“内存回收机制”。当大模型阅读了数万行的日志或代码后,引擎能够优雅地将历史观测结果(Observation)进行掩码替换(Masking)或局部截断,从而在保住模型推理意图的同时,避免了 API 的 Token 溢出报错。
但是,解决“内存溢出”只解决了 Agent 的短期存活问题。
当你给 Agent 下达一个宏大的长程任务——比如:“帮我将这个基于 Python 的用户服务重构为 Go 语言,并补充完整的单元测试和 Makefile”时。这个任务可能会跨越几个小时,经历上百个 Turn 的 ReAct 循环。
在这个漫长的过程中,由于我们的 Compactor 会不断地将早期历史压缩(甚至彻底掩码),大模型很快就会产生严重的长程失忆症:
传统的 AI 框架是如何解决这个问题的?它们通常会在引擎内部引入极其复杂的图数据库(Graph DB)、向量数据库(Vector DB),甚至在代码里维护一套庞大无比的 State Machine(状态机)来随时记录 Agent 的每一步进度。
但在驾驭工程(Harness Engineering)中,这种做法不仅极大地增加了维护成本,更致命的是:这些藏在黑盒里的内部状态,人类开发者根本无法直观地查看、调试和干预。
今天,我们将学习顶级原生 Agent(如 OpenClaw 的底层框架)最反直觉、也是最优雅的设计哲学:Externalized State(状态外部化)与基于纯文件系统(File-based)的持久化记忆。 并且,我们将为其引入一个极其重要的架构开关:Plan Mode(计划模式)。
状态外部化:把复杂的状态机变成肉眼可见的 Markdown
与其在 Go 语言的内存里定义一个复杂的 type AgentState struct { TodoList []string, ArchitecturePlan string },然后想方设法把它序列化存进 Redis 数据库,不如我们直接教大模型使用最朴素的文件系统。
在我们的架构中,Agent 已经被限制在一个特定的工作区(WorkDir)中,并且它已经拥有了 read_file、write_file 和 edit_file 这三个完备的原子 I/O 工具。那么,它完全可以将自己的“大脑状态”直接写在本地的文件里。
在顶级 Coding Agent 的极简哲学中,一切长程任务的追踪都可以通过引导 Agent 读写两个约定俗成的文件来完成:
为什么文件系统记忆法(File-based Memory)是高维的优雅?
这种设计看似“简陋得不可思议”,实则蕴含了驾驭工程的智慧:
绝对的透明与可观测性:你在终端里或者 VS Code 中,随时点开工作区根目录的 TODO.md,就能清清楚楚地看到 Agent 现在到底在干嘛,接下来打算干嘛。
零成本的人机协同(Human-in-the-loop):如果 Agent 的规划走偏了,你不需要调用任何 API 或者写控制台指令去修改它的内部状态。你只需要像编辑普通文本一样,手动修改一下 PLAN.md 然后保存。当 Agent 在下一个 Turn 再次读取它时,状态就自动纠正了。
天然的跨会话与断电持久化:哪怕你的 go-tiny-claw 进程崩溃了 100 次,只要 TODO.md 还在工作区里,你重新启动程序并告诉它:“继续执行任务”,它读取文件后就能无缝恢复进度。
极致的内存节省:与其把所有的长程规划和已完成任务清单都死死塞进昂贵的 Context Window(这迟早会被 Compactor 压缩掉),不如把它们沉淀在物理文件中。Agent 只需要在每轮循环的开头或迷茫时 read_file 一次,就能以极低的成本唤醒关键记忆。

引入 Plan 模式(Plan Mode)开关
如果你仔细思考一下,上述的“强制写 PLAN.md 和 TODO.md”机制虽然强大,但它真的是万能的吗?假设用户只是发了一句:“帮我查一下当前目录下的日志报错”,或者“用 bash 运行一下 go version”。
如果大模型无论接到什么简单的命令,都极其死板地先去创建一个 PLAN.md,写上“我的计划是运行 go version”,然后再去更新 TODO.md 打个勾……这不仅极其浪费 API Token,而且会让用户觉得这个 Agent 是一个注重繁文缛节的“官僚”。
在业界的实践中(例如 Claude Code),这种重型的记忆管理机制通常是一个可选的“计划模式(Plan Mode)”。
错误自愈:上下文感知的 Error Recovery 提示模板注入机制
在实际的开发或运维场景中,前行的道路从来不是一帆风顺的。大模型在执行具体的微观任务时,经常会“踩坑”:
试图 read_file 一个拼错名字的文件,收到底层 OS 抛出的 no such file or directory。
试图 edit_file 替换一段代码,却因为幻觉写错了 old_text,导致我们手写的模糊匹配算法直接拦截报错。
试图用 bash 执行一个刚写好的 Go 代码,却发现遇到了 undefined: atomic 这种编译错误。
在传统的框架中,工具底层的 Error 会被原样变成一段纯文本,作为 ToolResult 直接丢给大模型。而大模型在面对这些生硬的报错时,表现往往令人抓狂:它要么只会机械地道歉:“对不起,文件没找到”,然后直接放弃任务;要么陷入盲目的试错:连续三次生成一模一样的、错误的 old_text 去尝试 edit_file。
为什么聪明的大模型在面对报错时会变得如此笨拙?在驾驭工程中,这被称为**“报错信息的不可操作性”**。
我们要让底层引擎在捕捉到错误时,不再只是冷冰冰地陈述报错,而是化身为一位“资深导师”,直接把“锦囊妙计(Recovery Hints)”塞进上下文中,引导大模型走向正确的“自救道路”。
认知重塑:报错不应只是陈述,而应是“行动指南”
让我们回到大模型(CPU)的视角。当它收到一个报错:“Error executing edit_file: 在文件中未找到 old_text”时,它的训练权重中存在无数种应对策略:放弃、猜测另一个字符串、或者报告给用户。
如果没有强有力的外力干预,大模型往往会遵循“最小阻力路径”——比如直接瞎猜一个新的 old_text,而不是老老实实地去重新调用 read_file 查看文件的最新内容。
顶级 Harness 引擎(如 Claude Code / OpenClaw)深刻地认识到:当工具调用失败时,仅仅返回原始的 Error Log 是远远不够的。必须基于当前的工具和错误类型,向 Context 中注入带有强烈倾向性的恢复建议。
