“把这个配置里的端口改成 8080。”
模型可以提出一个 edit_file 调用,却不会因为自己输出了 JSON,文件就真的发生变化。程序要找到工具、校验参数、执行修改,把结果写回上下文,再决定是否请求模型下一轮。
如果只看这一件事,Agent Loop 已经足够说明问题。但当 Agent 变成一个完整应用,事情会继续扩大:模型服务要替换,工具要按插件装卸,会话要持久化,任务要取消,运行中的副作用要在作用域结束时清理,甚至同一套能力还要按不同配置隔离。
这时,rpi Agent Loop 和 Cordis 会同时出现在讨论里。它们都能被描述为“Agent 框架”,也都能参与模型和工具的调用,但它们解决的不是同一个层次的问题。
结论先放在这里:
rpi Agent Loop 主要负责一次任务怎样向前推进;Cordis 主要负责一个 Agent 应用由哪些服务和插件组成,以及这些部件怎样被注入、替换、隔离和销毁。
Cordis 的
agent-loop插件本身确实实现了具体的 Agent 执行循环。因此,准确的比较对象不是“rpi loop 对 Cordis 核心”,而是“rpi-agent loop 对 Cordis 的 agent-loop 插件”,或者比较两套完整 runtime。
本文沿着同一个 edit_file 动作,从简单闭环一路看到运行时边界,再讨论各自的优缺点和组合方式。源码事实以 rpi 提交 27bc919df41cc08846682774b48bd2aa0a33e158、rpi 0.5.0,以及 deepseek-harness 提交 47f943859bef60e4160492346772ded9b24f765a、vendor Cordis 4.0.1、agent-loop 0.1.0-rc.5 为基线。没有同条件性能测试,本文不把 Rust 和 TypeScript 的语言差异写成吞吐结论。
先从一次工具调用开始
先暂时不看插件、事件总线和会话恢复,只看最小任务:模型需要修改一个文件。
用户:把 config.toml 里的 port 改成 8080
↓
模型:请求调用 edit_file(path, old, new)
↓
程序:找到 edit_file,检查参数并执行
↓
程序:得到成功或失败结果
↓
把 assistant 调用和 tool result 放回上下文
↓
模型:根据结果回复“已修改”或继续处理错误
这里有一个经常被忽略的事实:模型提出的是结构化动作,程序才是动作的执行者。
因此,最小的 Agent Loop 可以写成下面这样。它是解释流程的伪代码,不是任一项目的完整实现:
loop {
let assistant = call_model(&messages, &tools).await?;
messages.push(assistant.clone());
let calls = collect_tool_calls(&assistant);
if calls.is_empty() {
break;
}
let results = execute_tools(calls).await;
messages.extend(results);
}
messages.push(assistant.clone()) 不能省略。工具结果需要对应前面的调用 ID,下一次模型请求也需要看到“模型刚才要求做什么”。只把“修改成功”塞回上下文,模型会丢失这条结果属于哪一个调用。
这个小循环还没有处理很多现实问题:
- 同一条 assistant 消息中的多个工具调用是串行还是并行;
- 工具参数不合法时,是让模型修正,还是直接终止;
- 模型输出被截断时,半个 JSON 能不能执行;
- 用户在工具执行期间发来新要求,什么时候消费;
- 取消发生后,已经写入磁盘的修改怎么办;
- 一次任务结束后,未完成的后续任务放在哪里。
这些问题仍然属于“执行控制流”。它们关心的是:当前这一步完成后,下一步是什么。
更准确地说,这些问题属于执行控制流;“取消后文件是否回滚”还涉及工具副作用和上层事务边界,不能只靠 loop 解决。
rpi Agent Loop:把执行不变量集中在一条路径上
rpi 的 rpi-agent 把循环需要的几类输入拆成了明确接口:
AgentContext:系统提示词、已有消息和工具集合;StreamFn:向模型发起请求并消费流式响应的入口;AgentTool:工具的 schema、参数处理、执行和进度回调;AgentLoopConfig:模型配置、取消信号、执行模式和 hooks;AgentEmitter:把消息、工具和轮次进度通知给宿主。
调用方不需要把具体 Provider、终端 UI 或某个会话存储实现塞进 loop。它提供模型请求函数、工具对象和事件出口,loop 负责把这几种能力串起来。
同一个 edit_file 如何走过 rpi
从固定提交的 agent_loop.rs 看,一次运行大致经过下面这条路径:
run_agent_loop
→ 合并 prompts 与 AgentContext
→ run_loop
→ stream_assistant_response
→ convert_to_llm / StreamFn
→ 收到完整 Assistant 消息
→ execute_tool_calls
→ prepare_tool_call
→ AgentTool::execute
→ 构造 ToolResult
→ 回写 current_context.messages
→ 再次请求模型,或进入停止判断
run_agent_loop 先把本次新增的 prompts 合并进已有 context。随后 run_loop 驱动内层的模型—工具往返。
stream_assistant_response 负责把 Agent 内部消息转换成模型层能理解的上下文,提取工具 schema,然后通过 StreamFn 获取响应流。流式增量可以及时发给 UI。当前 assistant 消息通常要等到终止事件,才会形成可执行的最终版本。
界面看见了一段工具调用增量,不代表程序已经可以执行它。只有完整消息进入工具调用提取阶段后,程序才会把其中的 ToolCall 交给执行路径。
execute_tool_calls 之后还有一层准备工作。程序按调用名称查找 AgentTool,运行工具自己的参数准备逻辑,再按 schema 校验参数。随后它执行 before_tool_call 等检查。校验通过后,程序才调用工具的 execute,并把结果包装成带有 tool_call_id、tool_name、内容和错误标志的 ToolResult。
工具失败通常也会变成工具结果,而不是立刻丢掉整个上下文。比如 edit_file 没找到目标文本,模型下一轮可以看到失败原因,再决定重新读取文件或调整替换范围。
需要把 rpi-agent 和完整 rpi runtime 分开。rpi-agent 的 current_context.messages 是当前运行中的工作上下文,不等于已经落盘的 session。
沿着同一个 edit_file 任务继续向外看,rpi-harness 的 run_core_with_entry 先保存用户输入,再构造 AgentContext。运行期间,FrameRecordingEmitter 记录流式进度,SettlingEmitter 在消息定稿和工具开始等边界提交需要持久化的记录。运行结束后,harness 再处理未提交的新增消息和恢复状态。
loop 负责让结果回到下一次请求,harness 才负责让这次运行在进程重启后有可追踪的记录。
取消也有同样的边界:AgentLoopConfig.signal 会把信号传给 Provider 和工具的子 token,但它不会撤销已经写入文件的副作用。工具实现、harness 的记录层和上层应用要共同决定取消后的状态;rpi-agent 不会替它们提供事务回滚。
rpi 把哪些不变量放在 loop 里
rpi 的优势不只是“代码少”,而是很多执行规则能在一处被看见和测试:
| 执行问题 | rpi 中的承担者 | 直接收益 | 付出的代价 |
|---|---|---|---|
| 模型消息何时可执行 | stream_assistant_response 与 run_loop | 不会把半截流式 JSON 直接当作工具调用 | 宿主必须理解最终消息与增量事件的区别 |
| 工具如何匹配和校验 | prepare_tool_call 与工具 schema | 未知工具和坏参数能变成明确结果 | 权限边界仍要由工具和宿主另行实现 |
| 工具结果如何续接 | execute_tool_calls 与 context 回写 | 下一次请求有稳定的调用—结果对应关系 | context 仍是当前运行内存,不自动等于持久化会话 |
| 多工具如何排序 | loop 的串行/并行策略 | 完成事件可以按实际完成顺序发布,结果消息仍可按原调用顺序整理 | 并行工具的副作用冲突要由工具声明和宿主负责 |
| 何时停止 | stop reason、取消信号、轮末 hook、工具终止结果 | 停止入口相对集中,容易建立测试矩阵 | 复杂应用若有很多外围状态,仍要自己接入这些检查点 |
有一个特别能说明 rpi 执行语义的例子:如果模型因为长度限制结束,而当前消息中有工具调用,代码会把这批调用视为截断失败,不执行其中任何一个。它没有根据“某个参数看上去已经完整”做局部猜测。
并行工具还体现出两种顺序:工具完成事件可以先报告较快完成的 B,但批次结果回写时仍按模型提出的 A、B 顺序整理。这样,UI 的即时进度和 transcript 的稳定顺序可以同时保留。
rpi 还区分内外两层循环。内层处理工具续接和 steering 消息,外层检查当前任务本来要结束时是否又来了 follow-up。因而“模型这次没有工具调用”不一定意味着整个 Agent 运行马上结束。
这就是 rpi Agent Loop 的核心本质:它把一次运行中的控制流、消息顺序和停止语义集中起来,让调用方替换模型和工具,却不必重新发明这条闭环。
Cordis:问题变成“应用里装了什么”
再看同一个 edit_file。如果只需要一个模型、几个工具和一次调用,rpi 的边界已经足够清楚。但一个完整 Agent 应用通常还会遇到另一类问题:
- 当前应用启用了哪些服务;
agent依赖哪个 LLM、工具注册表和 session;- 不同用户或不同 profile 是否使用不同配置;
- 一个插件撤销时,谁清理它创建的事件监听和异步任务;
- 热替换或隔离运行时,旧实例怎样退出;
- 事件应该广播给所有监听者,还是按顺序、串行或 waterfall 传递。
这些问题不是某一轮要不要继续,而是整个应用怎样组织和收回能力。
Cordis 的核心抽象是 Context。它可以理解为一个带有依赖注入和生命周期的服务容器。服务被挂到 context 上,其他模块通过 inject 获取服务,不必到处直接 import 全局单例。
在 Cordis 的事件系统里,事件可以使用不同的分发语义:普通 emit、parallel、serial 和 waterfall。它们分别表达监听者是否并行、是否按顺序完成,以及前一个结果是否影响后一个处理者。
Fiber 提供运行作用域。通过 effect 注册的异步工作可以被追踪;作用域取消时,任务收到取消信号;作用域进入空闲状态后,运行时等待清理,最终由 scope.dispose 收回资源。
它解决的不是“这一次工具调用返回什么”,而是“这个服务创建的工作在它不再存在后还会不会继续跑”。
因此 Cordis 的主要收益来自组合能力:服务、插件、配置、事件和生命周期可以被应用装配,而不是全部硬编码进一个 Agent 类。
Cordis 里的 agent-loop 到底是什么
这里要拆开三个名称:
agent抽象:应用侧可以创建、注册和管理 Agent;agent-loop插件:提供默认的 Agent Loop driver 和具体执行实现;- Cordis runtime:承载 Context、依赖注入、事件、Fiber、作用域和插件生命周期。
在 deepseek-harness 的架构里,可以看到类似这样的关系:
Cordis Context
├─ ctx.agents
├─ ctx.agentLoop
├─ ctx.llm
├─ ctx.tools
├─ ctx.sessions
└─ system prompt / profile / events
agent-loop 插件
├─ AgentLoop service
└─ ReactLoopAgent
├─ send / wakeDriver
├─ preStep
├─ turn / step
├─ buildRequest
├─ ctx.llm
├─ ctx.tools
└─ session events
AgentLoop 服务负责创建或恢复具体的 ReactLoopAgent。
在 index.ts 中,prepare、createAgent 和 setupAndPublish 把实例接入当前 Context。实例沿 send、wakeDriver、preStep、turn、step 和 buildRequest 推进。
agent.ts 中的请求路径再调用 ctx.llm 和 ctx.tools,并把 request、assistant、tool 等事件追加到 session。
实例结束时,dispose 负责拆除发布的服务和相关作用域。Fiber 的取消、空闲等待与 scope.dispose 共同决定异步工作何时真正收回。
这里也有一个边界:Cordis 的作用域可以停止运行中的任务,但已经由 edit_file 写入磁盘的内容不会因为 scope.dispose 自动恢复。取消和外部副作用仍需要工具或业务层提供更高层的补偿策略。
这条链路说明 Cordis 的 agent-loop 插件确实拥有具体执行逻辑。它把结果记录和资源收回放在更多服务与生命周期边界上,而不是全部收拢在一个 loop 函数里。
所以,Cordis 并没有把循环“变没”。它把循环放进了一个可以被注入和替换的服务里。LLM、工具调度、session 日志和作用域清理,都可以成为循环穿过的服务边界。
这张图只回答层级关系,不回答谁更快。上半部分是 rpi 集中的一次执行闭环,下半部分是 Cordis 承载 agent-loop、LLM、工具、session 和 Fiber 的应用运行时。图中的箭头表示职责或调用关系,不表示性能数据。
图 1:rpi Agent Loop 与 Cordis + agent-loop 的层级关系。图中函数和服务来自固定版本源码;箭头表示调用、注入、观察或生命周期关系,不表示吞吐和延迟。点击图片可打开交互版本。
沿着同一个 edit_file 再深入一层
你只需要先记住这个结论:rpi 的 loop 把执行控制集中在一条路径上,Cordis 把应用组织拆到服务和作用域里。下面继续看同一个 edit_file 动作如何分别走过两种边界。
rpi:先进入 loop,再穿过可替换接口
调用方
→ run_agent_loop
→ AgentContext + prompts
→ run_loop
→ StreamFn 请求模型
→ 提取 ToolCall
→ AgentTool 查找、校验、执行
→ ToolResult 写回 transcript/context
→ stop / next turn / follow-up
在这条路径里,控制中心是 loop。StreamFn 和 AgentTool 是可替换的能力边界,但它们被同一个 loop 约束在统一的消息和停止语义下。
如果调用方要接入自己的 UI,可以订阅 AgentEvent;如果要接入持久化,可以在 loop 外由 harness 保存消息和运行记录。rpi-agent 不会自动把宿主应用的所有服务装配好,这正是它保持简单的原因,也是它的边界。
Cordis:先进入 Context,再由服务组装出 loop
Harness 应用
→ mount / patch Cordis Context
→ inject AgentLoop service
→ create / resume ReactLoopAgent
→ preStep / turn / step
→ buildRequest
→ ctx.llm.stream
→ ctx.tools 执行
→ session.append 事件
→ inbox / next step
→ Fiber 取消、whenIdle、scope.dispose
在这条路径里,具体 Agent 仍然要循环,但它不再独自拥有所有依赖。模型、工具、session 和系统提示词从 context 服务中取得;运行记录是事件的一部分;任务的结束还要和 Fiber 作用域、插件 dispose 对齐。
这会带来一个重要变化:rpi 的主要不变量集中在 loop 里,Cordis 的主要不变量分布在多个服务和生命周期边界之间。
例如,rpi 里“工具结果必须以正确调用 ID 回到 context”主要由工具执行路径和消息类型承担。Cordis 里除了这个执行关系,还要保证 session event 能按顺序追加、下一步消息进入正确 inbox、实例结束时相关 fiber 被取消,服务撤销时不留下监听器。
Cordis 不是因此“不可靠”。它是在维护更多种类的不变量:Agent 执行只是其中一类。
优缺点不是功能清单,而是控制权交换
rpi Agent Loop 的优势
1. 执行路径短,源码阅读成本低。
从 run_agent_loop 进入 run_loop,模型请求、工具准备、结果回写和停止判断有一条清晰主线。排查“工具为什么没有执行”“结果为什么没有进入下一轮”时,搜索范围相对集中。
2. 替换模型和工具不必重做控制流。
StreamFn 隔离模型调用,AgentTool 隔离工具实现。测试时可以使用 faux provider、内存执行环境和固定工具结果,直接断言消息顺序和文件状态,不必让真实模型决定测试是否走到目标分支。
3. 执行顺序和边界更容易做成确定性测试。
截断调用不执行、并行完成顺序与 transcript 顺序分离、取消 token 向工具传播,这些都可以围绕 loop 建立测试。对一个只想可靠完成任务闭环的应用来说,这种确定性比“能挂多少插件”更重要。
4. 集成形态灵活。
调用方可以自己提供 UI、Provider、事件处理和持久化,也可以在进程边界外通过 RPC 使用 Agent。需要的能力可以逐步接入,不必一开始接受完整应用容器。
rpi Agent Loop 的代价
1. 宿主需要自己承担应用级装配。
当系统出现多个 Agent、多个 profile、多套工具注册表和不同 session 时,调用方需要自行定义它们的生命周期和依赖关系。rpi-agent 提供的是执行核心,不是完整插件操作系统。
2. 生命周期规则容易散落到 loop 外。
取消一个 run、停止一个工具、保存一条 session event、销毁一个外部资源,可能分别由调用方、harness、工具实现和 Provider 负责。只使用 Agent Loop 并不会自动获得统一的作用域管理。
3. 扩展之间的组合约束需要宿主维护。
如果插件 A 依赖插件 B,或者不同插件要监听同一个事件,宿主需要决定注册顺序、冲突处理、隔离范围和卸载策略。接口提供了接入点,却不等于自动解决应用拓扑。
Cordis 的优势
1. 能把 Agent 应用拆成可组合服务。
LLM、工具、session、system prompt、Agent factory 和策略都可以成为 context 中的服务。替换一个服务时,依赖它的代码通过注入获得新实现,不必把全局引用逐处改掉。
2. 生命周期是一等问题。
Fiber、effect 和 scope dispose 让异步任务、取消和资源释放有共同的作用域。对于会动态加载、隔离或热替换插件的应用,这比单独传递一个 cancellation token 更完整。
3. 事件机制适合做横向能力。
日志、指标、审计、UI 更新和策略钩子可以作为事件监听者接入。不同分发模式还能表达并行观察、串行处理和 waterfall 式修改,而不是所有模块都直接调用 Agent 内部方法。
4. 适合组织复杂应用,而不仅是执行一次任务。
当一个系统里同时存在多个 Agent、工具包、会话、模型路由和用户 profile,Context 能提供共同的组织边界。应用可以按作用域创建一组服务,再整体销毁或替换。
Cordis 的代价
1. 概念和间接层更多。
一次工具调用不再只是“调用 loop 的 execute”。读者需要同时理解 Context、inject、service、事件、Fiber、scope 和具体 Agent 实现。问题可能出在循环本身,也可能出在依赖没有注入、事件没有发布或作用域提前销毁。
2. 扩展性会转化为调试成本。
服务越可替换,真实运行路径越不由单个文件决定。要知道 ctx.llm 到底是哪一个实现,需要追踪 profile、插件加载顺序和 context patch。模块之间的边界更松,调用链的发现成本也更高。
3. 生命周期抽象要求团队遵守约定。
有 Fiber 不代表所有外部副作用都能回滚;有 dispose 不代表每个插件都正确注册清理逻辑;取消 token 也不能撤销已经落盘的文件修改。如果团队没有把 scope、session 和副作用边界写清楚,运行时能力越强,错误组合的空间也越大。
4. 单体小应用可能会显得过重。
如果应用只有一个 Agent、几个固定工具和一个明确的运行入口,完整插件运行时带来的 Context、事件和生命周期层,可能超过问题本身的复杂度。此时为了“未来可扩展”提前引入它,未必是收益。
怎样公平地比较两者
可以用三个问题先对齐比较对象。
问题一:你是在解决一次执行,还是组织一个应用?
如果最急的问题是:
- 工具调用结果没有回到下一轮;
- 多个工具的结果顺序不稳定;
- 模型输出被截断却执行了半个调用;
- 取消后 loop 仍然继续请求;
那么你需要先看 Agent Loop 的控制流。引入更大的运行时不能替代对这些不变量的处理。
如果最急的问题是:
- 不同 profile 要挂不同模型和工具;
- 插件要动态启停;
- 一个 Agent 创建的任务需要整体取消;
- session、事件、工具和策略要由多个模块共享;
那么你面对的是 runtime 组织问题。单独增加 loop 的 hooks,可能会把应用容器重新造一遍。
问题二:你需要替换能力,还是需要管理能力的生命周期?
rpi 的 StreamFn 和 AgentTool 解决的是“可以替换什么”。
Cordis 的 Context、inject 和 Fiber 继续解决“替换以后由谁拥有它、谁依赖它、何时停止它”。
这两者不是同义词。一个工厂函数可以让 Provider 可替换,却不自动告诉应用旧 Provider 何时释放连接;一个事件监听器可以观察工具结果,却不自动决定它应该在 session 销毁时取消订阅。
问题三:你愿意把控制流分散到多少边界?
集中式 loop 的好处是读者可以在一条调用链里建立心智模型。分布式 runtime 的好处是能力可以由多个服务独立演进。
代价也正好相反:前者把宿主责任留在外面,后者把一次执行拆到更多 seam。选择哪一个,取决于系统真正需要多少独立变化的部件,而不是“框架越大越先进”。
选择建议:三种规模,三种组合
只想嵌入一个可靠的 Agent 闭环
选择 rpi-agent 或类似的集中式 loop 更直接。
提供一个模型请求函数、一组工具、一个事件接收器和取消信号,就能先验证任务闭环。测试可以从固定的 edit_file 开始:检查模型调用是否产生、工具参数是否被验证、结果是否按调用 ID 回写、没有工具调用时是否结束。
此时不要先为插件市场、热卸载和多租户作用域设计完整 runtime。那些问题尚未出现,提前引入只会增加调试路径。
需要多服务、多插件和动态生命周期
选择 Cordis + agent-loop 更合理。
这里的关键不是 Cordis 会“替你完成更聪明的推理”,而是它让 Agent Loop 处在一个可以组合的服务环境里。模型、工具、session、事件和运行作用域可以按应用需要装配。
但要把 agent-loop 插件和 Cordis 核心分开验收:先确认具体 turn/step 的消息和工具语义,再确认 context 注入、事件顺序、Fiber 清理和 dispose 行为。不能因为插件被成功加载,就推断任务闭环和生命周期都已经正确。
大型系统:分层组合,而不是二选一
第三种方案是借鉴两者的分工:
应用 runtime:Context / plugin / session / scope / events
↓
执行 driver:Agent Loop / turn / tool scheduler / stop semantics
↓
能力接口:LLM / tools / execution environment
在这个结构里,runtime 负责“有哪些能力、谁拥有它们、什么时候销毁”,loop 负责“本次请求如何推进、工具结果怎样回到下一轮”。
这也是最值得保留的边界:不要让插件运行时直接吞掉所有执行语义,也不要让 Agent Loop 变成一个同时管理 UI、插件、session 和全局配置的巨型对象。
最后:先问自己缺的是哪一种“不变量”
rpi Agent Loop 和 Cordis 的核心区别,不在于一个用 Rust、一个用 TypeScript,也不在于某一方列出的功能更多。
它们维护的是两组不同的不变量:
- rpi 关心 assistant 消息、ToolCall、ToolResult 和下一轮请求之间的连续性;
- Cordis 关心服务依赖、事件分发、作用域、插件和异步任务之间的连续性。
一次 edit_file 让这条边界变得具体:
- 模型提出修改,loop 负责让程序执行并把结果带回模型;
- runtime 负责决定
edit_file从哪里注入、属于哪个 Agent、产生的事件写入哪个 session,以及这个任务结束时哪些资源一起清理。
如果你的问题是“下一步怎么走”,先读 loop。如果你的问题是“整个应用里的这些部件怎么共存、替换和退出”,再读 runtime。两者最后可以组合,但不应该在一开始就把它们当成同一个东西。
源码入口与版本边界
本文使用的 rpi 入口:
rpi-agent/src/agent_loop.rs:run_agent_loop、run_loop、stream_assistant_response、工具执行与停止路径;rpi-agent/src/types.rs:消息、工具结果和上下文类型;rpi-agent/README.md:rpi-agent 的定位与公共接口;rpi-harness/src/agent_harness.rs:run_core_with_entry、上下文构造、运行恢复与持久化装配;rpi-harness/src/frame_progress.rs与rpi-harness/src/settle.rs:流式进度和消息定稿的记录边界。
本文使用的 deepseek-harness 入口:
packages/core/agent-loop/src/index.ts:AgentLoop服务、Agent 创建、发布与销毁;packages/core/agent-loop/src/agent.ts:send、preStep、turn、step与请求构造;docs/architecture.zh.md:ctx.agents、ctx.agentLoop、ctx.llm、ctx.tools和 session 的整体关系。
固定提交只是本文的事实边界。两个项目的主分支继续变化时,函数名、插件版本和生命周期细节都应重新核对。