AI Pulse
📡 X 信号

想跑几小时的编码AI,压缩记忆反而会害死它

开源 agent 框架 Mastra 的联合创始人 Sam Bhagwat:编码 agent 能不能跑数小时,取决于 harness 把对话当成什么——函数调用会死,永远开放的通道不会;压缩是 agent 的坟场,观察记忆才是解药。《解剖 harness:构建一个能连续运行数小时的编码 agent》 压缩是编码 agent 的坟场。你没法把目标丢给 harness 然后去睡觉,如果它每 40 分钟就把自己阉割一次的话。

一个 agent 要升级成 harness,必须独自做到三件事:管理自己的上下文、扛过重启、在没有人在旁边纠错的情况下守住之前的决定。大多数 agent 三件事一件都做不到。让我带你过一遍 Mastra 在过去几个月里学到的全部经验——如何从 naive agent 循环进化成一个能连续跑数小时的强大 harness: • 线程持久化——任务做到一半关掉终端,重新打开,对话带着同样的模式、模型、token 计数和记忆设置回来 • 实时任务列表——一个持久的待办清单,agent 自己写、自己更新、在宣布完成前自己检查 • 中断 / 排队 / 转向——在运行中的任务上打字覆盖、排队一个后续任务、或者中止并改道,因��对话是一条开放通道,不是一次函数调用 • 会停下来等人类的工具——askUser 和计划审批,带纯文本回退,让同一个 agent 能在 CI 里无头运行 • 计划 → 构建交接——批准计划后 harness 自动切换模式,从只读规划进入执行 • 审批链——一条有序的规则链(逐工具拒绝 → YOLO → 会话授权 → 类别策略 → 询问)决定什么能跑 • 子代理——隔离起来做无偏评审,或者 fork 出来保持提示词缓存温热 • 崩溃恢复 / 多端——两个终端共享同一条线程而不互相踩踏 是时候从第一性原理开始搭建一个能长时间运行的编码 agent 了。

走起!什么是编码 agent?编码 agent 是一个带工具的循环里的 LLM——这些工具允许它读文件、跑命令、改代码,然后用行动的结果决定下一步。

编码 agent 沿着一条自主性光谱运行,大多数现代工具同时支持自主和监督两种行为。这是你通常会在 agent 上看到的两种模式: • 交互模式。你留在循环里,看着每一步并随时转向。

这是 Cursor、Windsurf、Cline 这类 IDE 内置工具的默认行为,也是你在终端里附加运行 Claude Code 时的行为。• 无头模式。你丢出一个目标,然后拿到一份 diff 或 pull request,没人盯着。

Claude Code、OpenAI Codex 和 Google Jules 都这么跑,Cursor 的后台 agent 也是。Mastra Code 更偏向自主行为。它也更难构建,因为一个你走开不管的 agent 必须自己管理上下文、扛过重启、在没有人在循环里纠错的情况下守住你之前的决定。

为什么 naive agent 循环会失败 naive 版的 agent 循环把每条消息和工具结果追加到一个不断增长的数组里,然后在每一轮把整个数组发给模型。demo 没问题,生产环境就不行了。下面是 agent 循环在用户毫无察觉的情况下失败的三种方式。

1. 上下文腐烂 默认情况下,原始历史随着每次工具调用和响应不断增长。随着过时结果、被取代的编辑和中间推理不断堆积,上下文的信号密度下降。模型必须读更多文本才能得到更少的有用信息。

危险之处在于没有任何东西报错,所以模型会在有用信号早已耗尽之后继续作答。更大的上下文窗口也解决不了这个问题,因为一个在 18 万无用 token 上执行任务的 agent,会显著劣于一个在约 4 万高质量 token 上执行同样任务的 agent。2. 有损压缩 对上下文腐烂的常见回应是压缩。

agent 等到��下文接近上限,把所有东西总结一遍,用摘要替换掉原始历史。纸面上这似乎可行,但这份摘要丢掉了需求的精确措辞、你提供的任何具体指令、或者你之前标记过的任何边界情况。这是个常见 bug,不少 Claude Code 用户都描述过。

会话在压缩边界之后立刻读起来明显变差,agent 开始推翻它在摘要之前做过的决定。3. 无状态会话 第三种失败出现在会话之间。没有线程存储,每次会话都从零开始,之前的决定和文件级上下文全部丢失。

所以你只能把上下文粘进 CLAUDE.md 文件,每次开新会话都要重新解释架构——这让你自己变成了 agent 的记忆。跨越所有这些问题的共同点是:agent 从不停止产出,所以没有任何崩溃来警告你。harness 架构 一个设计良好的 harness 能把这些问题的绝大部分解决到位。

我说“到位”,是因为它们还没有被完全解决。但考虑到当前 LLM 架构的局限,harness 让我们在今天能走到最接近“可用 agent”的地方。Harness 是包在 agent 循环外面的那一层,负责:把对话当通道处理、两阶段事件转换、以及一个可直接渲染的展示状态。

它还加入了会停下来等人类输入的组件、会话和线程持久化、Mastra 的观察记忆、以及一条审批链。循环和外壳保持分离。你可以挑选不同的模型、模式、认证方式等等,而不用把它烧进核心。

Mastra 的 harness 如何结构化对话 harness 把对话当作一条保持开放的通道,而不是调用一次就等结果的函数。一次普通函数调用就像一次 HTTP 请求。你发出请求,模型流式返回响应,连接关闭,返回之后什么都不留下。

一条通道就像 WebSocket 或一直开着的 pub/sub 主题。harness 连接到一个对话线程一次,从那时起,它不断推送消息进去,并接收 agent 每一轮发出的所有东西。harness 拿回一个订阅,它移交:分块流、当前运行轮的 ID、以及中止或退订的控制权。

只要线程存在,这个流就带着 agent 在那条线程上发出的一切。底下垫着一个 pub/sub 主题,正是它让连接在任意单次运行结束后保持开放。通过发送消息,你就是在往这条开放通道上发布。

而且多个客户端可以同时订阅同一条线程,所以终端和网页视图一起跟随同一段对话。保持对话开放,让一次性调用做不到的四件事成为可能: • 你可以中断。在 agent 任务进行中打字,它会融入正在运行的工作而不打断它。

• 你可以排队后续任务。在 agent 还忙着的时候说接下来要做什么,harness 会先握住这条消息,在当前运行轮结束的瞬间发出它。• 你可以转向。

让 agent 停下去做别的���,harness 丢掉当前运行轮、清空一切排队中的东西、重新开始。• 它还能扛过崩溃。因为对话活在线程上而不是运行进程里,你可以退出、重开、重新附加到同一条线程,从上次停下的地方继续。

这四种行为,就是开放通道设计值得多写那些管道代码的原因。上面的每一层都靠这条主干线撑着。把原始模型输出转成结构化事件 如前所述,订阅给你的是原始 provider 分块。

把这些流变成稳定东西的工作落在 processStreamChunk 上。它把每一种原始分块类型映射成一个干净、语义化的 harness 事件。一个改名的字段、一个被拆分的分块类型、或者一个新的流式阶段,只会碰到这一个函数,下游什么都不用动。

同一个函数还处理那些不该原样显示在屏幕上的值。例如,当某个工具读取 API key 时,你不想把原始密钥打印在转写里。所以工具可以给它的结果挂一个展示转换——这是值的第二个、仅供显示的版本。

真实值仍然被存进消息并发送给模型。密钥/敏感数据渲染成 ••••••,而工具在真密钥上运行。把事件收敛成单一展示状态 我们说的这些事件,不过是来自用户和 agent 的一系列零散更新。

UI 不应该靠逐个重放这些事件来拼装它们。所以 harness 用一个 reducer 把每个事件折叠进单个对象。这个对象构建出来的展示状态持有屏幕画一帧所需的一切:agent 是否在工作、正在写的消息、哪些工具在跑、任何待处理的审批或提问、子代理活动、记忆进度、改动过的文件、以及实时任务列表。

渲染器只问一个问题:“世界现在长什么样?”它读这个对象然后绘制。它从不解析原始事件。

有两种监听方式,取决于你需要什么。• subscribe(listener) 按顺序给你每一个事件,什么都不合并。适合日志和分析,那里要的是完整记录。

• subscribeDisplayState(listener) 给你合并后的最新状态快照。适合终端或网页 UI,它们只关心当前帧。批量重绘 快照需要合并,因为一个流式 agent 每秒发出几百个事件,大约每个文本块一个。

每个都重绘终端会把它淹没。DisplayStateScheduler 处理这个。它在 250ms 窗口内批量处理普通更新,硬上限 500ms。

突发被压成每秒几次重绘,上限保证长时间流式期间屏幕仍然刷新。有些事件不能在这个批次里等。任何阻塞人类的东西都会插队:请求审批的工具、被挂起的工具、一个提问、等待审批的计划、agent 开始或结束、以及任何线程/模式/模型的变化。

比如 agent 问“我能跑这条命令吗?”,这个提示必须立刻出现,而不是最多半秒后。所以这些事件立即刷新。

每次刷新还会把快照过一遍 cloneDisplayState,深拷贝其中的 map、日期和嵌套数据。订阅者可以握住一帧,并相信下一个事件到来时它不会在底下被改动。工具如何暂停 agent 并请求人类输入 一个工具可以在执行中暂停。

从模型那边看,没什么特别——它只是调用了一个要花一会儿才返回的工具。底层,harness 一直把那个工具开着,直到你回答。这跑在一条回程通道上。

每次运行前,harness 给工具的上下文附加一个小对象——一条回到 harness 的私密线路,模型永远看不见。通过这条线路,工具可以在屏幕上画提示、注册一个提问或计划审批、读取和更新 harness 状态、并触达线程身份和中止信号。这条回程通道是刻意可选的。

比如,同一个工具在没有 harness 的 CI 管道里跑,它会回退到纯文本路径,继续工作而不是崩溃。用 askUser 提问 askUserTool 最清楚地展示了这个机制。这是来自我们代码库的片段。

(代码块省略,原文为 askUserTool 实现——注册回调、发出事件画提示、然后在一个等你回答才 resolve 的 Promise 上等待。) 工具向 harness 注册一个回调、发出一个事件让 UI 画提示、然后在一个直到你回答才会 resolve 的 Promise 上等待。一旦你回答,UI 把你的回复调给 agent。

harness 找到存储的回调、触发它、Promise resolve、工具返回结果。循环从暂停的地方精确地接上。等待接在 abort 信号上,所以 Ctrl-C 能干净地取消它,而不是让一个工具永远挂着。

纯文本回退意味着同一个 agent 可以在没有人在场可问的无头管道里运行。交接计划 submitPlan 工具复用了回调加 Promise 的机制,注册一个计划审批并发出 planApprovalRequired。(代码块省略,原文为 submitPlanTool 实现——与 askUser 相同的纯文本回退。

) 当计划被批准,harness 切换到构建模式,等到流完全静止后再做任何下一步。那次交接把 agent 从只读规划推进到执行,两次运行之间没有重叠。任务工具 任务工具都操作 harness 展示状态里的同一个任务列表。

其中三个会改动那个列表: • taskWrite 整体替换它,而 taskUpdate 和 taskComplete 按 ID 改单个任务 • 每次改动发出一个 taskUpdated 事件,让渲染出来的待办清单随着 agent 工作保持最新 • taskCheck 是只读的,读同一个列表确认一切完成,agent 才收尾 (代码块省略,原文为 assignTaskIds 实现——为任务分配稳定 ID,处理重复内容和可复用 ID。) 子代理:隔离或缓存复用 harness 为子代理提供两条路线。非 fork 路线启动一个全新的 agent,带自己的指令和受限的工具集。

它的 harness 上下文不携带线程 ID,所以父代理的历史和观察一概到不了它。当你想要一个不被父代理的推理左右的公正读者时,这种隔离就是重点。比如,“审查这段代码有没有 bug”的子代理,如果还没读过父代理论证代码为何正确的那些话,工作效果更好。

fork 路线用那种隔离换取 provider 的提示词缓存。它克隆父线程并跑在父 agent 上,所以模型看到的是它刚处理过的完全相同的对话,缓存保持温热,更便宜更快。为了安全,fork 保持每个工具的模式完全相同,所以请求仍然命中缓存,只替换被封锁的工具在运行时的实际行为。

例如,一个 fork 出的子代理试图派生自己的子代理或编辑共享任务列表,会撞上一个返回提示的桩然后不再前进。克隆体还被标记为 forkedSubagent: true,所以它不会出现在用户的线程列表里。会话、模式和线程持久化 模式就是 agent 的一套已保存配置。

每个模式有名字、可选的标签和颜色、默认模型、以及它运行的 agent。Build、Plan 和 Fast 都是模式。当你切换模式,harness 停掉正在跑的、记住你离开那个模式时用的模型、把你带到新模式、并带回你上次在那里用的模型。

所以如果 Build 设成一个模型而 Plan 设成另一个,你可以整天来回切换,每个模式都守着自己的模型。再也不用重新选。所有这些都和对话本身一起保存,而不是和运行的程序一起保存,这就是它扛过重启的原因。

对话记得你在哪个模式、每个模式用哪个模型、你花了多少 token、以及你的记忆设置——比如哪些模型做总结、它们在什么阈值介入。所以你可以任务做到一半关掉终端,之后重新打开,回到同一段对话,发现一切都在你离开的精确位置。一段对话还可以同时在两个地方打开,比如两个终端窗口。

为了防止它们互相踩踏,harness 会给它上锁,先拿到新锁再放开旧锁,如果失败就撤销这次交换。两个窗口永远不可能同时认为它们拥有同一段对话。当你去选模型,harness 帮你构建列表。

它从内置 provider 开始,加上你自己配置的模型,并检查你实际登录了哪些——查看你的环境变量和已保存凭据。它记住这个列表十秒钟,所以再次打开菜单不会重新检查每个 provider。工具审批 每个工具调用在运行前都会归结为三种结果之一:允许、拒绝、或询问。

harness 通过走一条有序规则链得出这个裁决,第一个匹配者胜出,所以检查的顺序本身就是策略。逐工具拒绝排在最前,作为绝对的封锁,下游任何东西都打不开它——这让你能围住单个危险命令,不管其他一切变得多么宽松。(代码块省略,原文为 resolveToolApproval 实现——per-tool deny → YOLO → per-tool policy → session grant → category → default ask。

) 只有一次调用通过了那扇门,YOLO 模式才轮到发言,自动批准任何未被拒绝的东西。再往下,harness 查你为工具本身设定的明确策略,然后是你之前在会话里批准过的工具,最后是工具所属的类别(read、edit、execute、mcp 或 other)——对照你的会话级授权和常设类别策略,当一路下来没有任何东西表态时,最终落到直接问你。观察记忆 你可能已经注意到 Claude Code(或其他编码 agent)会话撞上上下文窗口上限时的有损压缩。

为了解决这个问题,我们在 Mastra Code harness 里设计了观察记忆(OM)层。OM 是一个 LLM 总结器,在某个可自定义的阈值处激活(默认 4 万 token),把冗长的交流转换成小得多的东西(100 到 1500 token)、清空上下文、用观察者总结出的 token 替换它。相比纯粹压缩,它在更长会话里保住了多得多的信息。

harness 持有阈值、模型选择、失败策略、以及记忆事件的屏幕翻译。总结本身活在记忆包里。还有一个反思层,在观察记忆命中可自定义阈值时介入,总结观察日志。

观察记忆架构在幕后如何工作 这里有两个模型在工作。• 一个观察者模型阅读对话并写下结构化观察:决定、事实和状态变化。• 一个反思者模型在观察堆积起来后压缩它们,合并的同时保留完成标记和日期之类的东西。

你与之对话的主 agent 读三层: • 直到上下文上限的最近原始消息 • 观察日志 • 反思结果 完整未压缩的历史从不保留来撑大上下文。我们想要尽可能多地把高信号上下文交给 agent。例如,不是带着四十轮原始工具输出,agent 看到的是最后几条逐字消息、一段像“我们选了 Postgres、构建通过、auth 文件不许碰”的简短日志、以及上面一条压缩过的总结。

决定以决定的形式存活,而不是作为埋在摘要里的转述。什么会触发观察记忆 观察者默认设计成在 4 万 token 启动,但它会提前一点跑——按 20% 的间隔——所以观察通常在上限命中之前就准备好了。上限到来时,工作已经做完,harness 直接换入结果。

比如,我把观察阈值设为 3k token,然后让它读一篇约 4.2k token 的文档。观察者接管,把上下文从 4.2k 压到 0.1k token。OM 也可以在一段空闲期后触发;在自动模式下,它根据 provider 挑选空闲超时,以匹配各家让提示词缓存保持温热的时间。

比如 Anthropic 约 5 分钟、DeepSeek 和 OpenAI 约一小时、Groq 约两小时、Google Gemini 24 小时。OM 也会在 provider 切换时触发。新 provider 无法复用之前的缓存,所以给它一张紧凑压缩过的图景,比给一段长长的原始历史更好。

如果出于任何原因观察者或缓冲步骤失败,harness 会中止运行,而不是带着一张被污染的会话图景继续。Mastra Code 与 Claude Code、OpenAI Codex 对比 这些是你和编码 agent 度过普通一天时会碰到的点。竞品行为随版本变化,所以把另外两列当作快照而不是规格。

| 任务 | Mastra Code | Claude Code | OpenAI Codex | | --- | --- | --- | --- | | 跑长会话不降质 | 边跑边在后台蒸馏对话,让 agent 保持稠密的工作记忆,永不经历一次性总结丢弃 | 等到上下文快满,然后总结整个历史并用摘要替换 | 等到上下文快满,然后总结整个历史并用摘要替换 | | 从停下的地方继续 | 重开线程,任务中途回来,带着同样的模式、模型、记忆阈值和 token 计数 | 用 --continue 重跑恢复上次会话,或 --resume 从列表挑一个;CLAUDE.md 每次重新喂项目上下文 | 从 CLI 恢复过去的会话;AGENTS.md 每次运行提供项目上下文 | | 任务中转向 agent | 在运行任务上打字把提示折进去、为完成时排队下一条消息、中止并改道、或退出稍后把线程捡回来 | 打断正在运行的一轮并发一条新指令 | 打断正在运行的一轮并发一条新指令 | | 批准或封锁它要跑的东西 | 有序规则链裁决每次调用,逐工具或整类别设 allow/deny,外加一个在沙箱里自动批准一切的开关 | 危险动作前请求许可,你建立白名单让可信命令不再询问 | 从 suggest(批准每次改动)到 auto-edit(改文件、跑命令前询问)到 full-auto(在沙箱里无人值守运行)选一种审批模式 | | 选模型 | 跨 provider 一个选择器,每个模式一个模型,一行配置切换 | 只用 Anthropic 模型 | 只用 OpenAI 模型 | | 自己在其上构建 | 完全开源,harness 库和 CLI 都是,你能在上面发自己的 agent | 闭源 | CLI 开源 | 内置模式 Mastra Code 带四个模式加一个思考控制,每个为一种任务调校。• Build 是默认模式,有完整工具访问权。简单任务直接跑,多步任务交给一个实时任务列表。

跑在 anthropic/claude-opus-4-6 上。• Plan 是只读的。它产出一个固定形状的结构化计划:概览、复杂度估算(规模、风险、依赖)、有序步骤(文件、改动、为什么)、以及验证清单。

然后提交计划审批,跑在 openai/gpt-5.2-codex 上。批准计划会自动把 harness 切进 Build。这就是前面说的交接,现在由模式本身触发。

• Fast 为了速度砍掉规划阶段。它用训练知识回答一般编程问题,只有问题是项目专属时才读项目代码。跑在 cerebras/zai-glm-4.7 上。

• YOLO 是"You only live once“,基本上是唯一一个 agent 可以自动批准任何命令、访问任何目录、直到任务完成或失败才请求许可的模式。通常适合爱好项目或坏了也不花钱的代码库。跨所有模式,模型的思考等级保持恒定。

比如,如果你不想让 Claude Opus 模型花太久想它要采取的行动,可以用 /settings 或 /think 把思考设成 off,取值从 off 到 low、medium、high、xhigh。扩展和配置 harness 幸运的是很多 harness 是开源的,所以你总能按个人用例定制任何额外功能或插件。harness 里默认就有一些功能,比如 MCP 服务器、skills 和插件来扩展你的 agent。

超出这些内置选项的,你可以改 harness 代码加入你的功能。MCP 服务器 Model Context Protocol 是 Anthropic 设计的一个系统,用来给 harness 加新功能、访问额外工具和不同数据源,一行代码都不用改。用 MCP,你只要往 harness 的 MCP 服务器配置文件里加一段带所需 bearer token 的 JSON 片段。

完成后,你的 agent 就能访问这些新数据源或使用额外工具了。拿 Slack MCP 举例。如果你想访问 Slack 消息,把 Slack MCP 加到全局配置,就可以从编码 agent 里查询你的对话,完全不用重写 harness 的任何部分。

Skills Skill 是一个 markdown 文件,给 agent 提供某种任务模式的持久、可复用指令。它捕获那些本来住在我脑子里、每个会话都要重打的那些知识。比如项目约定、重复工作流的步骤、以及像”总是重新生成生成的客户端,绝不手改"这样的约束。

写一个就能免掉之后每个会话的重建上下文工作。我通常借助 agent 来起草 skill——跑一次任务,然后让它写下它学到了什么。收尾 退一步看这些复杂性,有几件事你在任何 agent harness 上都能看到: • 对话是一条你保持订阅的通道,不是调用一次的函数,这就是 agent 可中断、可转向、重启后能恢复的原因。

• 原始模型输出被翻译成干净的事件,然后折叠进单一快照,所以任何 UI 只读快照而不用解析流。• 工具可以通过回程通道暂停并等待人类,没人在场回答时回退到纯文本。• 会话状态活在线程上,所以关掉终端仍然保留模式、模型、token 计数和记忆设置。

同样的架构适用于任何需要可中断、持久、并在多个界面上展示的 agent——无论是客服 agent、研究 agent 还是运维 agent。Mastra 把它打包成可复用的 Harness 类,你可以直接在其上构建。原文: #Agent工程 #Harness设计 #编码agent

查看 X 原帖

订阅 AI Pulse

每天 08:00 · 12:30 · 18:30 · 23:50 更新