数据截至 (上游 commit dad6f5196773)
01 · 运行时内核:大脑出指令、引擎跑指令
本章讲什么: LobeHub 整个 agent 体系的地基是
packages/agent-runtime。这个包做了一件事——把"决定下一步干什么"和"真的去干"彻底掰成两半。读完你应该能回答:一条指令长什么样、一步循环里发生了什么、状态凭什么能被存到数据库再捞出来接着跑。
本章不讲上下文怎么拼(第 2 章)、不讲模型流怎么接(第 3 章)、不讲工具怎么真正落地执行(第 4 章)、不讲多 agent(第 6 章)。
1. 这是什么(零基础也能懂)
一句话定义: @lobechat/agent-runtime 是一个不含任何业务实现的 agent 执行框架——它定义了"指令"这种数据格式,并提供一个把指令一条条执行掉的引擎。
1.1 它要解决的问题
假设你要做一个能调用工具的 AI 助手。最朴素的写法是一个 while 循环:调模型 → 看有没有 tool_calls → 执行工具 → 把结果塞回去 → 再调模型。
这个循环写一次很容易。麻烦在于 LobeHub 要让同一个循环跑在四个完全不同的地方:
| 跑在哪 | 工具怎么执行 | 消息存哪 |
|---|---|---|
| 浏览器(纯客户端) | 浏览器里的插件 iframe | IndexedDB |
| 服务端(云端会话) | 服务器进程 + 数据库事务 | PostgreSQL |
| 云沙箱 | 远程容器 | 服务端代管 |
| 本地 CLI / 桌面端 | 本机文件系统、shell | 本地库 |
如果把"决策"和"执行"写在一个函数里,这四份代码就得复制四遍。LobeHub 的解法是切一刀:
- 决策产出的是一段可序列化的 JSON(叫 instruction,指令)。
- 执行是一张可替换的函数表(叫 executors,执行器)。
于是"大脑"只有一份,"手脚"按环境换一套。
1.2 一句话直觉
把 Agent 当成下棋的人,AgentRuntime 当成棋盘和裁判。人只说"车二平五"这句话(指令),真正把棋子挪过去、判合不合规、记时的是棋盘。换个场地(线上/线下)只换棋盘,下棋的人不用重学。
这个类比只用于建立直觉,下文一律用 Agent / AgentRuntime / instruction 这三个术语,一词一义。
1.3 用起来什么样
包里自带一个能真跑的最小例子 packages/agent-runtime/examples/tools-calling.ts(bun run simple 启动)。它的骨架就是外层驱动循环:
// packages/agent-runtime/examples/tools-calling.ts:243-298,示意精简
const agent = new SimpleAgent(); // 大脑:自带 modelRuntime 和 tools
const runtime = new AgentRuntime(agent); // 引擎
let state = AgentRuntime.createInitialState({
maxSteps: 10,
messages: [{ content: '现在几点?顺便算一下 15 * 8 + 7', role: 'user' }],
});
let nextContext; // 第一次是 undefined,引擎自己推断出 user_input 相位
while (state.status !== 'done' && state.status !== 'error') {
const result = await runtime.step(state, nextContext); // 走一步
for (const event of result.events) render(event); // 事件流:渲染 / 落库 / 打点
state = result.newState; // 新状态
nextContext = result.nextContext; // 引擎告诉大脑"下一步你面对的是什么相位"
}
三点先记住,后面每一节都在展开它们:
while循环在包外面。AgentRuntime只提供step()(单步),谁来反复调它、什么时候停,由执行面自己决定。step()是纯函数式的: 收(state, context),返回{ events, newState, nextContext },不改传进来的state。nextContext是引擎回传给大脑的接力棒,它带着phase字段,大脑靠它判断"我现在处在循环的哪一环"。
2. 顶层全景(它大概怎么转)
2.1 大脑与引擎的职责切分
┌──────────────────────── 一次 step() ────────────────────────┐
│ │
│ AgentState ──┐ │
│ (可序列化护照) │ │
│ ▼ │
│ ┌───────────────┐ 指令(纯 JSON) ┌──────────────┐ │
│ │ Agent「大脑」 │ ─────────────────►│ AgentRuntime │ │
│ │ runner() │ │ 「引擎」 │ │
│ │ 只做判断 │◄───────────────── │ executors │ │
│ └───────────────┘ nextContext └──────┬───────┘ │
│ ▲ (带 phase) │ │
│ │ ▼ │
│ └──────── newState ◄────── events(流式出口) │
│ │
└─────────────────────────────────────────────────────────────┘
怎么读这张图:左边只做决定、右边只做执行;两者之间来回传的东西全是可序列化数据(指令、context、state),没有闭包、没有回调、没有对象引用。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Agent | 接口。核心方法 runner(context, state) 返回一条或一组指令,完全无状态 | packages/agent-runtime/src/types/instruction.ts:74-118 |
AgentRuntime | 引擎。step() 拿指令、找执行器、跑、汇总事件和新状态 | packages/agent-runtime/src/core/runtime.ts:27 |
AgentInstruction | 指令联合类型,14 个成员,大脑与引擎之间唯一的"话术" | packages/agent-runtime/src/types/instruction.ts:396-413 |
AgentState | 可持久化护照:消息、状态机、用量、成本、工具集快照…… | packages/agent-runtime/src/types/state.ts:21-167 |
AgentRuntimeContext | 单步上下文,核心是 phase 字段 | packages/agent-runtime/src/types/instruction.ts:16-68 |
GeneralChatAgent | 默认大脑实现:一台按 phase 分支的相位状态机 | packages/agent-runtime/src/agents/GeneralChatAgent.ts:47 |
InterventionChecker | 审批判定器:一次工具调用要不要拦下来问人 | packages/agent-runtime/src/core/InterventionChecker.ts:30 |
UsageCounter | 纯累加器:token、调用次数、费用 | packages/agent-runtime/src/core/UsageCounter.ts:10 |
2.3 主线走一遍(不进代码)
一条用户消息在这个内核里的旅程:
- 外层把
AgentState.messages填上用户消息,调runtime.step(state)。 - 引擎发现最后一条消息是
user,构造出phase: 'user_input'的 context(runtime.ts:919-940createInitialContext)。 - 大脑收到
user_input,返回{ type: 'call_llm', payload: {...} }。 - 引擎找到
call_llm执行器,流式跑模型,把每个 chunk 变成llm_stream事件,最后回传phase: 'llm_result'的 nextContext。 - 大脑收到
llm_result,看有没有 tool_calls:有就派工具、没有就finish。 - 外层循环拿新 state 再调
step(),直到status变done/error,或者被"阻塞态"挡住。
3. 指令协议:大脑与引擎之间唯一的话术
3.1 为什么指令必须是纯数据
只要指令是 JSON,它就能:跨进程传(客户端算好丢给服务端跑)、落库重放(审计、tracing)、被 mock(测试大脑时不用真跑模型)。这是整个包最重要的一条设计约束——AgentInstruction 的注释原话就是 "A serializable instruction object"(instruction.ts:392-395)。