Agent Engine
引擎把 ReAct 循环真正跑起来——模型想、做、看,一步接一步;它在内存里累积整轮上下文、随之自动压缩,并把每一步实时流式推给客户端。
一次运行,全程记着自己走到哪
公式与 ReAct 循环 已经讲过,agent 的核心是“想→做→看”不断重复的 ReAct 循环。Agent 引擎就是这个循环真正运转起来的地方:模型推理当前该做什么,用一次工具调用把决定付诸行动,观察返回的结果,再回到推理——一步接一步,直到任务完成。
它和一次普通的 chat completion 很不一样。chat completion 是一问一答、无状态的:接收一段文本,返回一段文本,交互结束后什么都不留下。而引擎的一次运行会横跨许多轮工具调用,全程记得自己走到哪一步、试过什么、得到过什么结果——正是这份持续累积的上下文,让它能连贯地推进一个长任务。但上下文只增不减,迟早会大到溢出窗口,所以引擎会随它增长自动压缩;与此同时,中间结果也不必等整轮跑完才给出,而是实时流式推给客户端,让用户看着任务一步步向前。
引擎只负责这个循环本身;DB、按任务的锁、额度账本、传输,都在它外层的 Task Orchestrator 里。也正因为把这些挡在外面,这套循环才被抽象成一个 runAgentLoop()(@zapvol/backend/src/agent/):server 的 web 服务和 desktop 的桌面应用各自在外面组装好参数、再消费它返回的流,两边调的是同一个引擎函数。
引擎入口:runAgentLoop
知道了引擎是个有状态的 ReAct 循环,接下来看它一次运行怎么起步。对外,引擎只有一个入口:runAgentLoop(params)——一次任务运行(一轮,turn)就从这里开始。调用方——server 端的 Task Orchestrator 或 desktop 的 agent handler——先把参数备齐(消息历史、sandbox、MCP 工具等)交进来,再消费它返回的结果。函数体只有四步,而且顺序被数据依赖锁死、不能重排:
- 构建输入(
buildAgentInputs)——先把这一轮要喂给模型的东西凑齐:用哪个 model、四层系统提示词、以及这一轮可用的工具集。其中提示词(instructions)和工具集(tools)谁也不依赖谁,所以并行构建、互不等待。 - 接线压缩、渲染起手消息(
buildCompactionSetup)——压缩要压得准,得先知道这一轮到底占了多少预算,所以这一步先实测:把当前真正下发的 instructions + tools 数一遍 token,据此建起压缩引擎。然后由engine.buildTurnInput把存储的历史渲染成这一轮的起手消息——[user: Task Context] + raw 窗口 + tail,同时从持久的meta:anchor里种入跨轮校准锚,让这一轮的压缩和上一轮对得齐。最后创建本轮每步要用的 step 压缩器。 - 预算快照(
saveBudgetSnapshot)——把刚测出的预算记一笔,供 admin 观测。它是 fire-and-forget 的(仅在 server 上做,隔离路径直接跳过),写与不写都绝不阻塞本轮——观测得给正事让路。 - 装配并启流(
new ToolLoopAgent+agent.stream)——万事俱备,接上prepareStep/onStepEnd/onToolExecutionEnd三个回调,循环就转起来:LLM 生成 → 工具执行 → 追加结果 → 继续生成,直到模型自己调用complete或撞上步数上限。有一点要留意:runAgentLoop返回AgentLoopResult { agentStream, stepUsages }的这一刻,流还没被消费——引擎只是把它交出去,由调用方自行驱动;等消费完、丢掉引用,底层的StreamTextResult就能被 GC 回收。
每步 prepareStep 做什么
四步装配是开场一次性的;prepareStep 则是每一步都要重跑一遍的那部分。它在每次 LLM 调用之前执行,按固定顺序做四件事:
先跑一遍 in-loop step 压缩(replay → 门槛 → reduce → project),在真正下发前把上下文压回预算内;再打一个 prefix cache 断点(markPrefixCacheBoundary),让断点以前的稳定前缀能被缓存命中、省下重复计费;接着把瞬态 reminders 追加在缓存前缀之外——它们每步都在变,混进缓存区会把缓存打脏;最后按 activeTools 把这一步真正暴露给模型的 MCP 工具面收窄。每步收尾时,onStepEnd 再累积一条 StepUsageData,记下这一步的开销。
什么会跨轮传递
引擎跑完一轮就把自己清空了,这一轮结束时不留任何状态。那下一轮怎么接得上?靠两样特意留存下来的东西。
一样是那条校准锚:它要等本轮消息落库之后,才由 saveTurnAnchor 写进快照存储的 meta:anchor,下一轮开场再由 buildTurnInput 种回去。另一样是内容寻址的快照存储——各段的摘要、各 part 的削减态——它们在循环途中就已写入,自然也留到了下一轮。
AI SDK 底层机制:
prepareStep的消息引用语义、onStepEnd/onEnd的双层触发、stopWhen的默认值兜底等反直觉点,详见 AI SDK → 运行生命周期 和 消息引用模型。
状态机
一次运行往往要跑上好几分钟,不能让用户对着黑屏干等。所以引擎一边跑一边广播自己的状态,让客户端 UI 实时把进度画出来:状态事件经 agent-state data part、通过 context.writeTransient() 发出(transient——只作实时广播,不进消息历史)。
其中 generating ⇄ executing 这一对来回,就是 ReAct 的核心——LLM 在生成文本和调用工具之间交替,直到发出完成信号或达到步数上限。compacting 只在某一步真的越过压缩触发线时才出现;它的意义是让 operator 看到“正在压缩上下文”,而不是误以为卡死。
注:初始化状态(
agent_building/context_building/mcp_connecting/agent_running)由 Task Orchestrator 的流构建阶段发射,不属于引擎内部。完整状态集见AgentStateKey(@zapvol/common)。
循环驱动的子系统
引擎真正的角色,是把下列子系统缝进同一个 ReAct 循环——输入侧(提示词、工具、MCP、Skills)、状态管理(记忆、压缩)、输出侧(流式)。每个子系统都有自己的页面,下面这张表是它们的地图。
| 子系统 | 在循环中的角色 | → |
|---|---|---|
| 提示词系统 | 四层提示词组装(策略 → 工具 → 记忆 → 环境),每轮构建 | 提示词系统 |
| 工具注册表 | 自描述工具配置,含能力过滤与压缩钩子 | 工具 |
| MCP 集成 | 把运行时发现的 MCP 工具桥接进工具集,含凭证作用域 | MCP |
| Skill 加载 | view_skill(name, path?) 按需加载 SKILL.md / L3 资源 | Skill 加载 |
| 记忆系统 | 跨会话持久记忆——显式保存 + 后台自动提取 | 记忆系统 |
| 上下文压缩 | append-only raw 流 + part 寻址 checkpoint,在 token 压力下每步 reduce | 上下文压缩 |
| 流式 | data-part 协议 + 双传输(SSE / WebSocket)+ Redis 支持的 SSE 续传 | 流式架构 |
| 规划 · 子代理 | write_todos 规划;task 生成隔离子代理做委派 | 任务规划 · 子代理 |
属编排器,不属引擎:后台任务队列(流结束后的扣费、摘要、记忆提取)由 Task Orchestrator 驱动,不在循环里。
相关阅读
- 任务编排——在 HTTP 上运行引擎的 server-only 宿主:锁、额度、持久化、传输。
- 概览——上一层:agent 本身与一次任务运行的形状。
- AI SDK → 运行生命周期 · 消息引用模型——循环所依赖的
ToolLoopAgent/prepareStep机制。