开场:长运行 Agent 的困境
为什么 Agent 跑不了长任务? 让 Agent 构建一个完整 Web 应用看似简单,但现实中充满了交接失败和上下文断裂的陷阱。
一句话速览:一口气做太多、做完就收工,两种典型失败模式。 核心洞察是:长任务的核心挑战是交接,动手做反而不难。
一、问题背景与两种失败模式
问题背景:给 Agent 的高层提示
给 Agent 的高层提示是 "Build a clone of claude.ai"。这不是一个小任务。一个完整的聊天应用需要认证系统、对话管理、流式输出、文件上传、Markdown 渲染、多轮历史……加起来可能有 200+ 个独立功能点。让 Agent 从零到一完成这样的项目,会遇到什么问题?
失败模式一:One-shotting(一口气做太多)
最常见的失败模式:Agent 试图在一次会话中完成所有功能,结果:
- 上下文窗口在实现到一半时被用完
- 下一个 Agent 接手时,面对半成品代码,只能猜测前任做了什么
- 大量时间浪费在让基本功能重新跑起来,新功能反而没时间做
- 即使有 Compaction(上下文压缩)也不够,压缩后的指令不够清晰,新 Agent 依然迷路
One-shotting 的典型时间线: Agent 1 开始写 → 写了 50% 功能 → 上下文用完 → Agent 2 接手 → 花时间修复半成品 → 上下文又用完。每次接手都忙于修复,推进陷入停滞。
失败模式二:Premature Completion(过早宣布完成)
Agent 看到已经实现了一些功能,就认为项目已经基本完成:
- 声明项目完成,实际上只完成了核心功能的 30%
- 缺乏任务清单,Agent 不知道还差什么没做
- 没有验证机制,以为做完了但没有端到端测试证明
类比:失忆的换班工程师
想象一个软件项目,每个工程师都只上一个班次。换班时完全失忆:不知道前一个人做了什么、为什么做、下一步该做什么。每个人坐到电脑前,打开一堆半成品代码,只能从零开始理解。这就是没有交接机制的长运行 Agent 的真实状态。
核心洞察:Agent 并不缺乏能力,它缺的是在上下文断裂时维持连续性的机制。解决交接问题,才能解决长运行问题。
二、方案一:Initializer + Coding Agent(双角色)
核心思路:把一个 Agent 拆成两个角色,一个负责规划,一个负责执行。每次交接都留下干净的状态。
双角色解决方案
Initializer Agent(只跑第一轮,负责从零到有): 搭环境、定计划、做第一次提交。
- 创建 init.sh 脚本搭建开发环境
- 写 claude-progress.txt 进度文件
- 把用户的高级提示展开成详细的功能清单(JSON 格式)
- 做第一次 git commit,确保仓库状态干净
Coding Agent(每轮都跑,负责从有到多): 逐个功能实现,持续推进。
- 读 progress 文件,了解当前状态
- 一次只做一个功能
- 完成后更新 progress 文件
- git commit 并写清楚做了什么
功能清单的设计
一种验证有效的做法是用 JSON 格式来记录功能清单,Markdown 不适合这个用途。原因是:模型更不容易错误修改结构化的 JSON,而 Markdown 容易被模型顺手重写。
// claude-progress.txt 中的功能清单
{
"features": [
{
"category": "authentication",
"description": "Email/password login with session management",
"steps": [
"Create login form component",
"Implement auth API endpoint",
"Add session cookie handling",
"Write end-to-end test"
],
"passes": false
},
{
"category": "chat",
"description": "Real-time streaming chat with Claude API",
"steps": ["..."],
"passes": false
}
]
}
Prompt 中使用强措辞:明确告诉 Agent 「不允许删除或修改已有的测试内容」。否则 Agent 会为了让测试通过而降低测试标准。
增量进度:一次只做一个功能
为什么「一次一个」是关键?
- 每次完成一个功能后,代码处于可合并状态:没有半成品、没有语法错误
- Git commit 提供回滚点:如果下一轮搞坏了什么,可以回到上一个干净状态
- Progress 文件提供上下文:新 Agent 不用猜做到哪了,直接读文件就知道
- 上下文窗口不会溢出:每轮只需关注一个功能的上下文,不会积累到爆
测试验证
Agent 容易以为做完了却没有端到端验证。它说「实现了登录功能」,但实际上按钮根本点不动。解决方案:明确要求 Agent 用浏览器自动化做端到端测试——要真正打开浏览器、点击按钮、验证结果,光有单元测试还不够。让 Agent 用 Puppeteer / Playwright 写 E2E 测试,作为功能是否真正 "passes" 的判定标准。
最终效果
200+ 功能的 claude.ai 克隆成功构建。 通过 Initializer + Coding Agent 的双角色方案,成功让 Agent 自主构建了一个包含 200+ 功能的完整 Web 应用。每个功能都有对应的 E2E 测试,代码始终保持可合并状态。
好的交接机制 = 好的长运行 Agent。进度文件、功能清单、增量提交,这些是让 Agent 能持续推进的最基本保障,一点也不花哨。
三、方案二:Managed Agent(脑手分离)
核心洞察:把 Agent 的思考和执行拆成独立组件,让系统在任何部分出错时都能优雅恢复。
类比:操作系统的虚拟化
操作系统虚拟化硬件:当你调用 read() 时,你不关心底层是 SSD、HDD 还是网络磁盘,操作系统帮你抽象了硬件细节。Managed Agent 做同样的事:虚拟化 Agent 的各个组件,让脑不关心手是哪个容器,让手不关心脑用的是哪个模型。
三个核心组件
- Session(会话日志): Append-only 的事件流,持久化存储。记录所有发生过的事情:用户输入、工具调用、模型输出。
- Harness(脑 / 大脑): 调用 Claude 并路由工具调用的循环。负责思考:决定下一步做什么、如何组织上下文。
- Sandbox(手 / 沙箱): 执行代码和编辑文件的容器环境。负责执行:运行命令、写文件、与外部系统交互。
为什么要拆开:宠物 vs 牛群
旧方案(宠物): Session + Harness + Sandbox 都在同一个容器里。容器挂了 = 会话丢失 = 无法调试 = 任务彻底失败。就像养了一只宠物,挂了就完了,无法替代。
新方案(牛群): 每个组件独立部署,互不依赖。容器挂了 = 工具调用失败 = Claude 决定重试 = 新建容器继续。就像牧场的牛,挂了重来一个,系统照常运行。
脑离开容器后的好处
关键改进: 容器变成工具调用:execute(name, input) -> string,对 Harness 来说就是一个普通函数。容器挂了 = 工具调用返回错误 = Claude 自行决定是否重试 = 自动新建容器继续工作。Harness 可以在容器启动前就开始处理,不需要等容器 ready。
- -60%:TTFT p50 降低
- -90%+:TTFT p95 降低(TTFT = Time to First Token,首 Token 响应时间)
安全的结构性解决
旧方案的问题: Agent 生成的代码和 API 密钥在同一个容器里,Prompt Injection 可以直接偷密钥。
Git Token: 在克隆仓库时注入容器环境变量,沙箱内可以用,但 Agent 看不到 token 值。
MCP OAuth Token: 存在外部 vault 里,通过代理转发 MCP 调用,沙箱内无法直接访问 token。
多脑多手
组件可以自由组合:多个 Brain(各自只是无状态 Harness,按需启动)+ 多个 Sandbox(各自是独立工具,可以传递给不同 Brain)。这意味着你可以让一个 Brain 同时操控多个 Sandbox(并行执行),也可以让同一个 Sandbox 在不同的 Brain 之间传递(接力执行)。组件之间完全解耦。
好的架构让组件可以独立失败和独立替换。脑手分离不只是性能优化,它从根本上改变了系统的可靠性模型:从「一个宠物挂了全完」到「任何部分都能重建」。
四、方案三:Session 不等于 Context Window
两个最容易混淆的概念:Claude 当前能看到的,和所有发生过的事。它们必须分离。
两个容易混淆的概念
Context Window(临时 / 有限): Claude 当前能看到的 Token。就像人的工作记忆:容量有限,过了就忘。每次对话开始时构建,用完就丢。当前推理所需的精选内容。
Session(永久 / 可回溯): 所有发生过的事件的持久日志。就像完整的录像:从头到尾什么都记着,永远可以倒回去看。Append-only,只增不减。所有原始事件的完整记录。
为什么必须分离
上下文管理的不可逆性:
- Compaction(压缩)和 Trimming(裁剪)都是不可逆操作,一旦压缩,原始细节就丢了
- 压缩时很难知道未来哪些 Token 重要,今天看似无关的信息,可能是明天关键决策的依据
- 如果压缩后原始信息丢了,就永远回不来了,这是信息论的基本约束
所以正确的做法是:原始事件永久保存在 Session 里,Context Window 只是从 Session 中临时取景的一个视角。丢了 Context 没关系,因为 Session 还在,随时可以重建。
Session 作为持久上下文对象
Session 提供一个类似数据库的查询接口(getEvents()):Brain 可以按需读取任意范围的事件,摆脱固定上下文窗口的限制。
// Brain 可以灵活查询 Session
const recentEvents = session.getEvents({
from: position - 100, // 从某个位置开始读
to: position // 读到当前位置
});
// 可以倒回到某个时间点
const beforeDecision = session.getEvents({
from: decisionPoint - 20,
to: decisionPoint + 5
});
// 可以过滤特定类型的事件
const toolCalls = session.getEvents({ filter: "tool_use" });
这就像 REPL 中的对象一样,LLM 可以写代码来查询和过滤 Session 中的事件。Brain 从任意位置开始读、倒回到某个时间点、重读某个决策前后的上下文。
Harness 的灵活性
- 优化 Prompt Cache 命中率: 保持前缀稳定,减少重复计算的 Token 开销
- 做上下文工程: 根据当前任务类型,选择性地放入最相关的历史事件
- Harness 是可换的: 不同模型可能需要不同的上下文策略,换 Harness 不影响 Session
核心对比
| 维度 | Context Window | Session |
|---|---|---|
| 持久性 | 临时,用完即丢 | 永久,持久化存储 |
| 内容 | 精选后的 Token | 所有原始事件 |
| 操作 | 只读(对 Claude 来说) | 可追加(append-only) |
| 大小 | 有限(模型上限) | 无限制 |
| 用途 | 当前推理 | 历史回溯、状态恢复 |
硬件类比
内存(RAM)≈ Context Window: 速度快、容量小、断电即失。CPU 正在用的数据必须在内存里,但内存不是用来长期存储的。
硬盘(Disk)≈ Session: 速度慢、容量大、断电不失。所有数据最终都存在硬盘上,需要时再加载到内存。
你不会把所有文件同时加载到内存里,那样内存会爆。同样的道理,你不应该把所有历史事件都塞进 Context Window,那样 Token 会爆。正确的做法是按需加载:Session 存全量,Context 取子集。
Session 是 Agent 的硬盘,Context Window 是内存,不要把内存当硬盘用。 把所有原始事件存进 Session,让 Harness 按需组装 Context。这样即使上下文压缩了、模型换了,历史永远不会丢。
结语
三种方案从不同层面解决长运行问题:双角色(Initializer + Coding Agent) 解决交接问题,一次一个功能、增量提交、进度文件是最基本的保障;脑手分离(Managed Agent) 解决可靠性问题,让组件可以独立失败和独立替换;Session 与 Context Window 分离 解决记忆问题,把所有原始事件存进 Session,让 Harness 按需组装 Context。
长任务的核心挑战是交接,动手做反而不难。 好的交接机制 = 好的长运行 Agent。