Agent 持续进化的秘诀:让你的 Agent 从大哥大进化成智能手机

最开始,我们的 ETF Agent 已经能调用 MCP 查询数据、通过 Skill 执行研究方法,还有自己的工具循环、生命周期钩子、执行回执和恢复机制。做一个特定的投资研究任务,这些能力已经不少。

但随着功能增加,我们发现另一件事:每增加一种能力,都要自己想怎么接入、注册、授权、运行、维护和升级。想要一个新的研究方法,得决定它怎样被模型发现、使用哪些工具、怎样保存成果;想在工具执行前增加检查,又要重新考虑生命周期、参数变换和失败恢复。

这有点像功能机时代:功能大多是厂商预装的,用户能做什么,取决于制造商是否愿意继续修改系统。

智能手机真正值得借鉴的,不是 App Store 的图标,而是核心系统和第三方应用之间出现了一条相对稳定的边界。应用可以持续增加,但不必每次都改造操作系统。

我们想让 Agent 也向这个方向发展:核心负责执行与治理,能力通过插件持续加入。

于是我们开始研究 Agent Plugins、Claude、Pi、Codex 等生态。但我们给自己定了一个约束:尽量让原插件运行在自己的 Agent Host 里,而不是再启动一个 Claude、Pi 或 Codex Agent 帮我们做任务。只有这样,扩展出来的仍是我们的 Agent,而不是几个 Agent 的拼接。

一、插件不是一个 Markdown 文件

一开始,插件似乎很简单:Skill 负责告诉模型怎么做,MCP 负责提供工具和数据。把两者放进一个包,就能扩展 Agent。

但真实的第三方插件往往还需要更多东西。

  • Claude 的 Hookify 可以在工具执行前阻止操作,也可以在 Agent 准备结束时要求继续,并向用户发送提示。
  • Pi 的 todo 扩展不只注册增删查工具,还维护状态,并在新会话开始时恢复。
  • Codex 的 cc-research 带有完整的研究方法:分工、报告文件、综合写作和 Stop 阶段的引用审计。

它们要求的不是一种抽象。只定义一个统一的 Plugin JSON Schema,很难让这三类行为真正运行起来。

我们后来把插件接入分成了两件事:

Compatibility Profile 负责解释外部生态的规则;Extension Host 负责把合法能力接入已有执行系统。

flowchart TD A["第三方原始插件包"] --> B["包发现与 Profile 解析"] B --> C["内容身份与审批"] C --> D["Generation Registry"] D --> E["统一 Extension Host"] E --> F["Skill / MCP / Tool"] E --> G["Lifecycle / Hooks"] F --> H["现有 Native Runtime"] G --> H

Agent Plugins 1.0.0 提供了便携包的核心约定,Skill 和 MCP 是基础组件;Claude、Pi、Codex 自己的 Hook、工具、会话状态则由各自的 Profile 处理。OpenCode 也保留了 Profile 入口。

我们并不追求对每个生态的完美兼容,而是优先支持有实际价值、能合理接入现有 Host 的能力。

这让后续扩展有了一条重要原则:插件可以提供方法、声明回调和请求工具,但不能因此重新定义我们的预算、来源授权、执行回执和恢复规则。

二、第一类坑:文件 hash 没变,不代表执行的代码没变

支持外部代码后,最自然的做法是给插件和依赖做内容锁:固定版本,记录内容摘要,启动前重新校验,通过后才能执行。

这个设计很合理,但我们后来在 Pi 扩展加载中发现一个更底层的问题。

你校验的字节,可能不是加载器真正执行的字节

Pi 的 TypeScript 扩展可以通过 Jiti 加载。我们构造过一个 resolve-only 的反例:插件和依赖的内容锁保持不变,但运行环境中的链接会影响实际模块解析目标。

于是出现了一个危险的间隙:

text 复制代码
已检查的插件内容:不变
已检查的依赖内容:不变
实际 Loader 解析目标:可能变化

内容相同,不等于执行身份相同。

这促使我们把选定可执行扩展的加载推进到另一条路径:

text 复制代码
原始插件与依赖
  → 受控转换
  → 固定转换选项、运行依赖和 Host 身份
  → 验证最终执行字节
  → 从同一份已验证 Buffer 加载

我们称这类产物为受审派生产物(Reviewed-derived Artifact)。它解决的不是"TypeScript 怎样变成 JavaScript",而是要回答:究竟哪份经过检查的代码,真的被执行了?

类似问题也出现在测试环境里。2026 年 10 月 9 日,原 Pi/Hookify 验收在 macOS ARM64 上被内容身份检查拒绝,尽管原始 lockfile 与 Linux x64 环境相同。

排查后发现差异集中在 esbuild 的平台包与安装器隐藏 lock 文件。我们重新构建并比较两个平台的依赖树,记录了 11 处差异,最终为两个平台保留分别验证的依赖集合,而不是放松内容锁。

这次经历让我意识到,插件版本不能只靠一串 SemVer 表达;安装实例、平台依赖、执行内容和当前代际都各有身份。

例如当前 Registry 持有的代际身份大致是:

ts 复制代码
type ExtensionGenerationIdentity = {
  scopeId: string;
  installationId: string;
  generation: number;
  contentHash: string;
  profileRevision: string;
};

可迁移的经验是:动态执行系统需要将"批准的内容""实际加载的内容"和"当前有权执行的实例"分开验证。

三、第二类坑:Hook 已经执行,为什么没有产生预期效果?

Claude Hookify 是我们验证生命周期兼容时很有代表性的原始插件。

它参与 PreToolUse、PostToolUse、UserPromptSubmit 和 Stop 等事件。我们保留原包文件,尝试让这些事件通过 ETF Agent 自己的生命周期执行器生效。

最早碰到的不是加载失败,而是输出语义没有接好。

1. 原始 Hookify 发出了提示,Host 却把它当成错误

原始 Hookify 的一组匹配测试曾出现:

text 复制代码
command.unsupported-system-message

Hookify 返回的是一个面向用户的提示,而 Host 当时没有正确区分这类展示内容与执行控制。

但不同 Hook 的输出本来就有不同职责:

输出 应该交给谁处理
工具执行前拒绝 当前工具调用的准入边界
工具执行后的结果修改 结果投影与再次校验
给模型的附加建议 下一轮模型请求装配
面向用户的提示 公共交付事件
停止阶段的继续建议 原运行的 Stop / Completion 边界

我们没有给 Hookify 特别开一个展示系统,而是把提示接入已有的用户事件通道。即使提示展示失败,也不应该悄悄消除原来有效的拒绝或阻断决定。

Hook 参加生命周期,不等于 Hook 有权接管整个生命周期。

2. 失败建议已经解析出来,却没进入下一次模型请求

更隐蔽的问题发生在另一次真实 Factory 测试中。这里使用的是合成命令 Hook,用来检验 ToolFailure 的 additionalContext 投影,并不是把这个行为冒充为原始 Hookify 的完整能力。

测试中,工具确实失败了,Hook 适配器也执行了一次,建议被解析出来;但真正提交的下一次模型请求里,缺少那条已批准的建议:

text 复制代码
Actual next gateway request
lacks AUTHORIZED_FAILURE_SUGGESTION

这不是 Hook 没执行,而是Hook 的结果没有穿过最后一段消费链路。

修复集中在既有异步上下文投影和模型请求装配上。最终请求包含有来源归属的建议,但仍保留工具原始失败事实,不将它改写成成功,也不把失败信息伪装成新的金融证据。

这和我们后来在 RAG、工具结果、Memory 中反复遇到的问题很像:

一项能力"产生了输出",与"下一次模型真的消费了它",是两种不同的可观测事实。

因此,扩展机制测试不能只断言 callback 被调用,还应检查最终工具动作、模型请求和用户事件的真实结果。

四、第三类坑:Pi 让我们重新理解了插件状态

Pi 的 todo 扩展看起来只是一个小工具:添加待办、列出待办、在新会话恢复状态。正因为简单,它非常适合测试宿主是否真正理解有状态插件。

它连续暴露了两个问题。

1. 三次回调成功,为什么只保存了一条待办?

2026 年 10 月 9 日的原始 todo 测试,有过这样一组真实观察:

观察项 修复前
插件回调成功 3
状态保存回执 1
状态冲突 2
最终持久化待办 1

回调都执行过,状态却没有按预期提交。原因并不神秘:几个写入同一插件实例状态的操作,进入了不合适的并发交错。

我们最后没有为整个 Agent 建立一个全局串行队列,而是复用 Native 执行器,把带 state:write 权限的工具在相应回合内作为有序单例结算;普通只读操作继续保持并发。

修复后,同一原始示例的状态按预期保存,修订号依次推进,终态回放没有增加新的写入。

这件事对插件系统非常重要:

工具允许并行执行,不代表它们的副作用可以并行提交。

是否串行,应该看它们共同修改什么状态、依赖什么原始回执,而不是简单地按"插件调用"统一排队。

2. 已经完成的 Run,为什么恢复时又执行了 SessionStart?

随后我们用 PostgreSQL 验证原始 todo 的恢复。

第一轮已经保存了工具回执和状态。关闭原来的连接和 Factory,重新创建数据库连接、Registry 与 Factory,再回放同一个已经终结的 Run。

第一次实际测试失败了:

text 复制代码
hook.effect_recovery_unavailable

原因是:Host 按"启动新运行"的正常路径,先触发了带副作用的 SessionStart,然后才准备返回已提交的旧结果。

但这次只是终态回放,不应该新增生命周期效果,所以回放路径本来就没有新的 effect executor。

这让我们明确分出两条执行路线:

text 复制代码
真正的新 Run
  → SessionStart
  → 从旧状态恢复 todo
  → 正常调用工具并提交新回执

已终结 Run 的历史回放
  → 读取原持久化回执
  → 检查当前展示和授权资格
  → 返回既有结果,不再派发初始化 Hook

修复的关键不是把 SessionStart 禁掉,而是只在不该产生新效果的回放路径上跳过它。后续真正的新 Run,依然要触发原始回调并恢复待办状态。

同一条证据链里还有一处测试陷阱:最初的测试把临时发布内容当作已经持久化的结果比较。后来我们调整为等待真实后台写入,再以持久化回执作为恢复对照。

这两次修正指向同一个原则:

恢复是消费已经发生的事实;重试或新运行才可能产生新的事实。

有状态插件要想成为可长期使用的扩展,这个原则比"API 形状一样"重要得多。

五、第四类坑:Codex 插件要的不是几个函数,而是一整套研究方式

Claude 主要考验 Hook,Pi 主要考验工具与状态;到了 Codex 的研究插件 cc-research,我们遇到的是另一个维度。

这个插件的方法大致包括:

text 复制代码
创建计划
  → 分配 Researcher
  → 各自收集资料与 Claims
  → 汇总 Sources
  → Synthesizer 写报告
  → Stop Hook 检查引用
  → 按反馈修订并交付

如果只是把它的 Skill 文本塞进模型上下文,仍然无法支持这套流程。模型需要实际可用的产物读写、角色交接和审计输入。

1. 一个冒号,让原始 Skill 解析失败

cc-research 的原始 SKILL.md 包含带冒号和空格的普通 description 标量。我们使用的严格 YAML 解析器拒绝了它。

类似下面这个说明性例子:

yaml 复制代码
name: research
description: Research: a complete workflow

我们继续检查 Codex 自己的 Skill 解析器,发现它对这类特定描述字段存在有限的兼容处理:先严格解析,失败时只针对符合条件的描述标量做引用修复,再重新解析。

于是我们将这条行为限定在 Codex Profile 内:只处理明确的单行 description 情形,保留原始正文、原始文件字节和内容 hash,不对其他无效 YAML 做万能"自动修复"。

这是兼容开发很典型的一课:

比起为某个插件偷偷改文件,更好的做法是理解原生态真实接受什么,再把差异收进有边界的适配层。

2. 原插件需要文件,但我们不想给它一整个 Shell

cc-research 的研究结果围绕一组逻辑文件交换:

text 复制代码
runs/<id>/
  meta.json
  plan.md
  sources-web-1.jsonl
  claims-web-1.jsonl
  sources-web-2.jsonl
  claims-web-2.jsonl
  report.md

最简单的方式是给模型一个工作目录和 Shell,让它直接创建文件。但 ETF Agent 原来已经有 Artifact Repository、执行回执、来源权限与取消机制。再引入一个随意读写的 workspace,等于多建一套相互竞争的存储事实。

所以我们沿既有 Artifact owner 加了有限的 Host 报告工具:

text 复制代码
report_write_strand
report_write_document
report_read
report_view

每个文件都有受限类型、逻辑身份、不可变版本和原始来源依赖。一个 web-researcher Child 只能写被分配的 sources/claims;Synthesizer 只能读取当前获准的固定视图,并写自己的报告。Main 负责收集、明确选择版本和协调交接。

文件存在于磁盘,不代表它已提交;报告正文里写了 source_ids,也不代表它获得了那些来源的权限。

我们还发现另一个实际竞态:当某个资料生产者在 Main 的下一次模型请求即将派发时被禁用,只在早期工具读取时检查权限不够。最后需要在真实派发边界重新核对原始生产者和依赖。

这些问题促使我们把报告工具当成带有来源与回执身份的文件操作,而不是随手加几个读写文件的函数。

3. JSONB 改了对象键顺序,报告却被拒绝读取

做 PostgreSQL 恢复测试时又遇到了一个非常具体的问题。

报告对象经过 JSONB 保存和读取后,键的插入顺序可能变化。如果用序列化字符串判定相等,就会把实际上相同的结构化数据误判为不同。

例如下面是说明性例子:

json 复制代码
{"source":"A","version":1}

和:

json 复制代码
{"version":1,"source":"A"}

它们字节不同,作为普通 JSON 对象却表达相同的键值关系。

修复采用结构化深度比较:字段、类型、值和数组顺序照样严格检查,但不把对象键插入顺序当成持久化业务身份。

这里最值得借鉴的是:先区分哪些内容必须逐字节一致,哪些内容只需要结构化等价。 原始文件、来源 hash 和经过签名的字节有自己的精确规则;数据库读回的结构化记录有另一套等价规则。混用会带来奇怪的恢复故障。

4. Stop Hook 的退出码 0,也可能只是"没有做审计"

cc-research 还有一个原始 audit.py,通过 Stop Hook 检查报告引用。

分析脚本时,我们发现退出码 0 不一定意味着引用审计通过:防重入、完成状态、过期资料或其他跳过条件都可能正常退出。

因此:

text 复制代码
进程 exit 0
  ≠ 已经执行了引用审计
  ≠ 引用审计通过
  ≠ 研究内容正确

原脚本还依据文件修改时间判断新鲜度。如果 Host 把旧报告重新物化到临时目录,修改时间变成了"现在",就可能无意中改变它的审计逻辑。

所以受控视图需要保留原始持久化时间,并让新的视图重新经过命令及工作目录绑定,而不是偷偷改路径、重置时间来让脚本通过。

这也是为什么兼容一个看起来简单的 Stop Hook,最后会涉及文件版本、时间语义、命令准入和下一轮反馈。原脚本完整修复链还在单独验收;这里讨论的是已定位的依赖与相应 Host 设计。

六、绕了一圈,我们最终把 Extension Host 做成了什么?

这段经历之后,再看 Extension Host,它的职责已经比较清楚。

包发现回答"包里有什么":识别 Core、Skill、MCP 和选定的兼容 Profile。发现一个声明,不等于运行它。

审批和内容身份回答"目前哪份东西有权执行":安装实例、审阅记录、内容锁、实际加载字节与授权都需要可核对。一个安装记录不能冒充当前批准;插件升级也不能沿用旧 generation 的执行资格。

Generation Registry回答"现在提供哪组能力":异步初始化先暂存注册结果,确认成功后再原子发布,避免一个初始化到一半就失败的插件留下半套可见工具。

Lifecycle 与工具适配回答"回调怎么进入真实执行链":Pre 的决定作用于调用前,Post 的结果作用于投影,提示走用户事件,Stop 走已有终止边界;工具统一受 Native 的身份、预算与取消约束。

Durable Effects回答"已经做过什么":插件状态、文件写入和命令效果由原始执行回执结算,恢复复用已结算事实;尚未明确结算的效果不能仅凭 callback 成功或临时文件存在而变成"已完成"。

可以把它理解成一句话:

Extension Host 是外部生态与现有 Agent Runtime 之间的适配和治理边界,不是第二个 Agent。

这让插件能继续扩展,但不必把每种插件的状态机、权限系统和工具循环全部塞进研究 Runtime。

七、从大哥大到智能手机,真正变化的是谁拥有系统能力

回看这个过程,我们最初想解决的是"怎样使用更多第三方插件"。

一路下来遇到的,却是加载器身份、Hook 输出消费、状态提交顺序、终态回放、受控文件交换,以及外部脚本对时间和文件系统的真实假设。

这些坑看起来分散,其实都在反复回答同一个问题:

一个外部能力接入后,究竟由谁负责使它的行为成为系统事实?

我们现在更倾向于这样的分工:

text 复制代码
Model       理解任务,选择下一步
Skill       提供研究方法与任务说明
MCP / Tool  提供可调用的具体能力
Plugin Host 装配第三方能力与生命周期
Runtime     负责执行、预算、取消和恢复
Application 持有用户、来源与持久化业务事实

第三方插件可以定义自己的方法、工具和回调,但不能让我们丢失这些已有的系统边界。

智能手机之所以可以持续演化,不是因为系统里预装了所有应用,而是因为应用能接入一套可复用的运行基础设施。

Agent 也是一样。

Agent 的持续进化,不取决于它内置了多少功能,而取决于它能否不断吸收新能力,同时让核心复杂度保持可控。

相关推荐
fellow992 小时前
DSH Desktop:把 DeepSeek Harness 变成真正的桌面应用
electron·agent·ai编程·deepseek
hpoenixf3 小时前
从“研究日志”到自然对话:我们如何让 AI Agent 的回答真正流畅起来
agent
运维开发王义杰3 小时前
让 AI 写文档、建 Skill,算知识沉淀吗?
agent
程序猿DD4 小时前
不知道自己的软件好不好用?让Agent扮演“新用户”来试试!
agent
hpoenixf4 小时前
Agent 已经生成答案,为什么还需要一个独立的发布组件?
agent
guslegend5 小时前
工程地基、Provider 与 ReAct 循环:Agent 核心能力的设计、实现与验收
人工智能·agent·react·架构设计·spring ai
10年前端老司机5 小时前
一次业务迭代:聊聊知识库多版本可追溯审核流程怎么做
人工智能·后端·agent
Fxrain5 小时前
[Paper-Reading] CUAs 安全与隐私威胁综述
人工智能·安全·agent·智能体