乐于分享
好东西不私藏

从 30 行代码推导出"一切皆插件":一个 Agent 运行时的演化之路

从 30 行代码推导出"一切皆插件":一个 Agent 运行时的演化之路

从 30 行代码推导出"一切皆插件":一个 Agent 运行时的演化之路

本文用逐步推进的方式讲清一个生产级 agent 运行时的架构:从一个人人都写过的 30 行朴素 agent 出发,每一步只引入一个真实的痛点,推导出解决它的最小抽象,一路推到"一切皆插件、配置即 agent"的终点。每一步的推导结果,都用 DeepSeek Harness(下称 dsh)的真实源码印证。

读法建议:每节开头的"痛点"先自己想十秒怎么解,再看推导——这套结构不是设计出来的,是被问题逼出来的。


第 0 步:所有人的起点——30 行的 agent

先写一个最朴素的 agent。它真的能工作,而且大多数 agent 项目的第一版就长这样:

messages = [{”role”: ”system”, ”content”: ”你是一个助手,可以调用工具。”}]while True:    user_input = input()    messages.append({”role”: ”user”, ”content”: user_input})    while True:        reply = openai.chat(model=”gpt-4”, messages=messages, tools=TOOLS)        messages.append(reply)        if not reply.tool_calls:            break        for call in reply.tool_calls:            result = TOOL_FUNCS[call.name](**call.args)# 直接调本地函数            messages.append({”role”: ”tool”, ”content”: result})    print(reply.content)

麻雀虽小,五脏俱全:有模型调用、有工具循环、有对话历史。接下来的每一步,都是这 30 行在真实世界里撞上的一堵墙。


第 1 步:换模型的墙 → "按名取用"的插座

痛点。老板说:换成 DeepSeek 试试;下周又说:再对比一下 Claude。你发现 openai.chat(...) 散落在代码各处,每换一次厂商就全局搜索替换一次,参数格式还不一样。

推导。问题的本质是:使用方通过 import 绑定了具体实现。解法是经典的依赖倒置——定义一个抽象接口,使用方只面向接口,实现在别处注册。对比改造前后,变化的只有一条边:

换厂商时,左图要改循环代码里的每一处调用;右图只是换一个注册进插座的适配器,循环那条边纹丝不动。

再往前推一小步:如果系统里这样的"可替换能力"不止模型一个(后面会看到多得很),那就值得把"注册与查找"本身做成通用机制——每个能力认领一个稳定的名字(插座),使用方按名取用,插什么由运行时决定

dsh 的落地。插座就是 ctx.llm、ctx.tools、ctx.fs 这样的服务键。技术实现颇妙:上下文对象 ctx 是一个 JavaScript Proxy,读 ctx.llm 不是读属性,而是触发 get 陷阱、去一张服务表里解析。DeepSeek 适配器只是注册在 ctx.llm 上的一个实现,换模型厂商 = 注册另一个适配器,循环与工具毫无感知。

本步沉淀的原则:能力按名取用,不按来源。


第 2 步:加能力的墙 → 注册表,以及"凡注册必可逆"

痛点。工具越加越多:读文件、跑命令、搜网页、查数据库……每加一个,都要改三处:TOOLS schema 列表、TOOL_FUNCS 分发表、系统提示词里的工具说明。三处忘一处就出诡异 bug。更麻烦的是有些工具要按场景开关(生产环境禁 shell),开关逻辑又是一堆 if。

推导。三处要改,是因为工具的"存在"被硬编码在使用它的地方。反转过来:让工具自己"报到"——向一个注册表注册自己的 schema、实现和提示词片段;循环每一步从注册表现场组装工具列表和提示词。加工具 = 多一次注册,删工具 = 少一次注册,三处同步问题消失。

但"注册"立刻引出一个新问题:注销呢?场景开关、热更新、出错回滚,都要求注册能干净地撤销。如果注销靠手工(记得 removeListener、记得从表里删),必然写漏。所以要一条纪律:任何注册动作必须同时交出"撤销函数",由框架记账,卸载时倒序执行。做到这条,"能挂上去"就等价于"能干净卸下来"——热插拔、按场景组合才有根基。

dsh 的落地。ctx.tools 是带作用域的工具注册表;插件注册的提示词段落和工具 schema,由 ctx.systemPrompt 在每个 step 统一组装。一切注册走 ctx.effect()——必须返回清理函数,插件卸载时框架倒序执行全部清理。这条纪律在 dsh 里是强制的:注册表贡献必须通过"HMR 安全测试"(卸载后观察注册确实消失)。

本步沉淀的原则:存在即注册,注册必可逆。


第 3 步:拦截的墙 → 事件,以及"让渡合同"

痛点。需求接踵而至:危险命令要人工审批;请求失败要重试;每次调用要计费打点;任务没做完时模型想收工,得逼它继续。你发现这些逻辑全都往那个 while 循环里塞——循环从 30 行膨胀到 800 行,变成没人敢动的上帝函数。

推导。这些需求有个共同形状:在循环的某个关键节点,插入一段"别人"的逻辑。与其让循环认识所有策略,不如反过来:循环在每个关键节点广播事件,策略作为监听器挂在事件上。循环回到骨架,策略全部外置。

但只有"广播"不够。细看这些需求,对控制权的要求不同:计费打点只需旁观(不许它拦截循环);审批需要否决权(能拦下工具调用);重试需要接管权(替循环做决定);"逼模型继续"需要协商(依次问一圈,有人反对就续)。所以事件必须分等级——每个扩展点要明确让渡多少控制权,这本身是契约:

合同
语义
适用
emit
纯通告,不许拦
打点、UI 刷新
waterfall
洋葱中间件,每层拿到 next:调用则放行,不调则否决
审批、改写请求、接管重试
serial
依次表态,有人出手即止
收尾协商

以工具调用为例,waterfall 把"要不要执行"变成一条每层都可否决的链:

循环只知道"发起了一次 tools/execute 事件",链上有几层守卫、各自什么策略,它一概不知。

dsh 的落地。循环的每个关口都是事件:消息进模型前有 agent/pre-step(waterfall,可改写可拒绝)、组装请求时有 agent/request(waterfall,可换模型改参数)、失败时有 agent/request-error(waterfall,可接管重试)、工具执行有三段瀑布 tools/pre-execute → execute → post-execute(审批/沙箱/超时守卫都挂这里)、收尾有 agent/turn-stopping(serial)。waterfall 的实现只有六行:

waterfall(...args) {  const cbs = this.dispatch('waterfall'args)  const inner = args.pop()                            // 最里层默认行为  const next = () => (cbs.shift() ?? inner)(...args)  // 每层决定是否放行  args.push(next)  return next()}

效果:dsh 的审批、守卫、重试、目标管理全是外部插件,循环本体常年不动。

本步沉淀的原则:骨架保顺序,决定让渡给事件;让渡多少控制权,是明确的合同。


第 4 步:崩溃的墙 → 日志成为唯一真相

痛点。跑到第 40 分钟进程崩了,messages[] 灰飞烟灭,只能从头再来。用户投诉"模型胡说",你想查它当时到底看见了什么上下文——查不到。想从第 3 轮分叉出一个"如果当时换个问法"的平行会话——做不到。

推导。三个"不可"(不可恢复、不可审计、不可分叉)同一个病根:agent 的状态活在易失的内存对象里。解法是把因果倒过来:

  • 旧世界:messages[] 是状态,日志(如果有)是它的副产品
  • 新世界:append-only 日志是唯一真相,内存里的一切都是日志的投影

注意新世界里箭头的方向:对话历史从日志推导而来,而不是反过来。

每个事实(用户消息、模型回复的每个流式片段、工具结果、轮次边界)发生时先写日志;要发给模型的对话历史,每次从日志现场推导。再配一条可被机器检查的铁律把口子封死:模型可见 ⟺ 已记日志——任何要进入模型请求的内容,必须先有对应的日志事件类型。

dsh 的落地。会话日志记录 turn/start、user/message、assistant/chunk(流式片段逐条落盘)、tool/result、turn/end 等事件;循环每次调模型前用 deriveMessages() 从日志投影历史——内存里不存在那个越 push 越长的数组。铁律由运行时不变量强制断言。于是:重启 = 重新投影;fork = 复制一段日志前缀;审计 = 读日志(且保证是全部);UI、遥测、转录全部从同一条流派生。持久化后端(JSONL/SQLite)只是这条日志的可替换实现——又是第 1 步的插座。

本步沉淀的原则:状态外置到唯一真相源,内存只是投影;用不变量看守入口。


第 5 步:回头一看 → 机制与领域无关,沉淀为组件模型

推导。走到这里,停下来盘点四步攒下的机制:

  1. 按名取用的插座(第 1 步)
  2. 可逆的注册表(第 2 步)
  3. 分等级的事件合同(第 3 步)
  4. ……以及它们共同需要的:谁先启动谁后启动、谁依赖谁、卸载时怎么级联

盘点后有一个发现:这四样东西没有一个和 agent 有关。它们回答的其实是一个更普遍的问题——"互不相识的零件如何组成一个软件:如何找到彼此、如何通信、如何共生共死"。既然与领域无关,就该把它们从 agent 代码里抽出去,沉淀成一个独立的组件模型;agent 只是它上面的一个应用。

这一步还顺手解决了"启动顺序"这个隐藏痛点:零件声明自己依赖哪些插座(inject: ['llm', 'tools']),框架在依赖齐备时唤醒它、依赖消失时级联卸载它——顺序不由人排,由依赖推导

dsh 的落地。这个组件模型就是Cordis(源自 Koishi 生态的开源框架,dsh 将其 vendor 进仓库、钉死上游 commit)。核心仅约2700 行,内容恰好是上面四样:Proxy 实现的上下文与服务表、effect 记账的可逆注册、五种派发模式的事件总线、Fiber 状态机管生命周期(PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED)与依赖唤醒。它完全不懂 LLM——正因为不懂,领域怎么变它都不用变

而一旦地基与领域无关,一个激进的推论自然成立:模型适配器、工具、日志、循环……全都可以是这个模型上的普通插件。没有特权内核,一切皆插件。

本步沉淀的原则:先立共存规则,再谈领域概念;规则与领域无关,才能成为不动的零点。


第 6 步:最后的焊点 → "agent 是什么"成为一份协议

痛点。一切皆插件之后,还剩最后一个焊死的东西:循环这个类本身。UI 要显示 agent 状态、hook 要监听 agent 事件、子 agent 要创建 agent——如果它们都 import 具体的循环类,那循环就永远换不掉;而循环恰恰是最该允许实验的地方(不同的推理策略、不同的多步编排)。

推导。对循环做第 1 步对模型做过的事:把"agent 是什么"(定义)从"agent 怎么干活"(实现)中剥离。但 agent 的定义不能只是几个方法签名——它的行为有时序(轮、步)、有扩展点(那些事件)。所以定义 =一个刻意很小的接口 + 一套事件协议:

  • 接口只定义"身体":身份 id、记忆 session、信箱 inbox、状态 status、领地 ctx,和几个投递/生命周期方法。没有 think(),没有 run(),没有工具列表,没有 LLM 引用——那些都是"怎么干活"。
  • 事件协议定义"行为":必须按约定的顺序和让渡合同发出 agent/createdagent/pre-stepagent/requestagent/turn-stopping……(第 3 步的合同在此成为定义的一部分)
  • 工厂槽位完成倒置:注册表 ctx.agents 自己不造 agent,只留一个 setFactory() 槽;实现包启动时把自己插进去。依赖箭头是反的——契约不认识实现。

于是"是一个 agent" = 实现小接口 + 发出这套事件。满足这两条的任何代码都是 agent;默认那个 ReAct 循环(dsh 里叫 ReactLoopAgent,约 1200 行)只是"契约的一种实现",整体可换,几十个依赖契约的插件毫无感知。

dsh 的落地与实证。契约层 core/agent 不到 300 行,几乎全是接口与事件声明。稳定性排序自此完全清晰,像一颗洋葱——越靠内变化越慢,依赖箭头只准由外向内:

实证:rc.5 → rc.7 两个版本间全仓库 539 个文件变动,内两圈(Cordis 源码 + 契约层 + 循环本体)一行未改。"越靠内越稳定"在提交历史里真实成立。

本步沉淀的原则:用协议回答"是什么",用槽位注入"怎么做";连核心引擎也只是契约的一种实现。


第 7 步:兑现 → 配置即 Agent

推导。现在盘点全部资产:零件按插座互联(1)、存在即注册且可逆(2)、策略挂在事件上(3)、状态在日志里(4)、共存规则独立成层(5)、定义脱离实现(6)。此时"组装一个具体的 agent"还剩什么工作?——只剩选择:挂哪些插件、每个什么参数、谁替换谁。

而"选择"不需要编程语言,一份声明式数据就够了。于是最后一步水到渠成:把组装清单做成分层配置,配置即 agent。

dsh 的落地。

  • 插件树由配置层叠而成:基础 bundle → 应用 bundle(web/headless)→ profile patch → 用户 patch → 命令行 --patch,后到者按行 id 覆写先到者;dsh --dump-config 打印的那棵树就是这个 agent 的完整定义
  • 能力接缝(seam)保证"换实现 = 换一行配置":Bash、终端 PTY、文件工具、LSP 四个使用方全部只调 ctx.fs / ctx.shell / ctx.subprocess,把实现从本地进程换成 E2B 云沙箱,四个使用方一行不改,整体搬进云端
  • preset 把粒度推到会话级:一份 agent.cordis.yml 就是一个"人格配方",同一进程里不同会话运行完全不同组合的 agent

agent 从此获得与"基础设施即代码"同构的性质:可版本化、可评审、可 diff、可复现。两个 agent 行为不同?diff 两份配置。

但要划清边界:配置是组合语言,不是编程语言——它只能选择、参数化、覆写;新零件仍然要用代码写成插件。分工是:

代码  →  制造零件(工具、适配器、策略、循环)配置  →  组装 agent(选哪些零件、什么参数、谁替换谁)

前六步抽象做得越干净,这一步配置的表达力就显得越大——"配置即 agent"不是特性,是前六步的兑现

本步沉淀的原则:代码制造,配置组合;声明式装配是干净抽象的红利,不是起点。


全程回放:七步推导一张表

撞上的墙
推导出的抽象
沉淀的原则
0
(起点)30 行朴素 agent
1
换模型要全局替换
接口 + 插座,按名取用
能力按名取用,不按来源
2
加工具改三处、开关靠 if
注册表 + 现场组装
存在即注册,注册必可逆
3
策略塞进循环,上帝函数
事件 + 五种让渡合同
骨架保顺序,决定让渡给事件
4
崩溃丢状态、不可审计
append-only 日志为唯一真相
状态外置,内存只是投影
5
机制与领域无关
沉淀组件模型(Cordis)
先立共存规则,再谈领域
6
循环类本身焊死
agent = 接口 + 事件协议 + 工厂槽
协议定义"是什么",槽位注入"怎么做"
7
(兑现)组装还要写代码
分层配置,配置即 agent
代码制造,配置组合

七步背后其实只有一个核心问题在反复出现:变化速度不同的东西,如何互不拖累?模型周周换、能力月月加、而"什么是一次对话"要年年稳——每一步推导,都是把"变得快的"从"该稳的"里剥出去,直到形成那颗洋葱:配置(天天动)⊃ 实现(可换)⊃ 契约(宪法)⊃ 组件模型(零点)。

最后用一个类比把全文钉住。这套结构与操作系统惊人地同构:

OS
agent 运行时
内核(不懂应用)
Cordis(不懂 LLM)
系统调用 / POSIX
契约层 + 日志事件词汇
调度器
默认循环(可换的那 1200 行)
程序(可执行文件)
组合配方(profile / preset)
进程
一个活的 agent

agent 是这套组件模型上的"进程":一个围绕会话日志组装起来、遵守 agent/* 行为协议的运行实例。精神状态外置在日志里,所以可 fork、可回放;每个器官按插座名临时接上,所以每个器官都可换。


尾声:检验与迁移

这七步原则不是 agent 特有的,迁移到任何系统设计都成立。最后给两条实践性的收尾:

如何检验你的分层是真的?看提交历史。如果宣称的"核心层"在活跃开发期真的不动(如 dsh:539 个文件变动,内两圈零改动),分层就是真的;如果每个 feature 都要碰"核心",那分层只是文档里的愿望。

如何决定下一个抽象?等墙,不预测墙。这七步的每个抽象都是被具体痛点逼出来的,而不是预先设计的框架蓝图。先让 30 行跑起来,撞墙时只引入解决那堵墙的最小抽象——这个推导过程本身,比任何终态架构图都更值得复用。


本文以 DeepSeek Harness 仓库(0.1.0-rc.7)为样本写成;Cordis 上游为cordiverse/cordis。文中行数与源码结构以写作时的代码为准。