Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
435 lines
30 KiB
Markdown
435 lines
30 KiB
Markdown
---
|
||
doc_id: architecture.runtime-core
|
||
title: "第一章:Log Is the Runtime——Maka 如何回放 Agent 的状态空间"
|
||
language: zh-CN
|
||
source_language: zh-CN
|
||
counterpart: ./runtime-core-architecture-draft.md
|
||
implementation_status: current
|
||
document_status: draft
|
||
translation_status: synced
|
||
last_verified: 2026-08-23
|
||
owners:
|
||
- maka-backend
|
||
---
|
||
<!--
|
||
Licensed to the Apache Software Foundation (ASF) under one
|
||
or more contributor license agreements. See the NOTICE file
|
||
distributed with this work for additional information
|
||
regarding copyright ownership. The ASF licenses this file
|
||
to you under the Apache License, Version 2.0 (the
|
||
"License"); you may not use this file except in compliance
|
||
with the License. You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing,
|
||
software distributed under the License is distributed on an
|
||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||
KIND, either express or implied. See the License for the
|
||
specific language governing permissions and limitations
|
||
under the License.
|
||
-->
|
||
|
||
# 第一章:Log Is the Runtime——Maka 如何回放 Agent 的状态空间
|
||
|
||
> 本章回答一个核心问题:Maka 如何保存一次 Agent 运行真正经历过的状态空间,并在下一轮、进程重启或新投影中重新得到它?答案是 Runtime Event Log。模型循环负责产生事实;Event Log 保存事实;Session、Run、UI、模型上下文和恢复逻辑都是这份日志的消费者或投影。
|
||
|
||
本文面向第一次进入 Maka Runtime 的工程师,也面向需要修改运行主链的维护者。读完前半部分,你应该能说清一次运行经过哪些边界;读完整章,你应该能定位主链代码,并理解修改终止、工具或持久化逻辑时必须保护哪些不变量。
|
||
|
||
本文描述的是截至 2026-08-23 已在生产主链中落地的实现。历史设计文档中的阶段性计划不作为当前事实。
|
||
|
||
## 从一个看似简单的请求开始
|
||
|
||
假设用户对 Maka 说:
|
||
|
||
> 找出这个项目里失败的测试,修复问题,然后重新运行测试。
|
||
|
||
如果 Maka 只是一个聊天应用,执行路径可能只有三步:把文字发给模型、等待回复、把回复显示出来。但 Agent 的真实执行过程更像这样:
|
||
|
||
1. 模型先阅读项目和测试输出。
|
||
2. 模型调用文件、搜索或终端工具。
|
||
3. 某些工具需要用户授权,运行暂时停住。
|
||
4. 工具可能持续输出,也可能失败、超时或被取消。
|
||
5. 工具结果回到模型,模型决定下一步。
|
||
6. 这组“模型 → 工具 → 模型”的步骤重复多次。
|
||
7. 最后,系统必须明确判断这次运行究竟完成、失败,还是被用户中止。
|
||
|
||
过程中,界面需要实时显示文本和工具活动;下一轮模型需要读到可信历史;应用崩溃重启后,系统不能永远停在“运行中”;用户按下停止按钮后,迟到的 provider 事件也不能把状态重新写成“完成”。
|
||
|
||
所以,Runtime 真正解决的不是“怎样调用一次 LLM API”,而是:
|
||
|
||
> 如何把一个包含流式输出、工具副作用、用户介入和进程故障的开放式循环,记录成一段可以重新解释和回放的事实历史。
|
||
|
||
## 先说结论:状态不是一张表,而是日志的函数
|
||
|
||
Maka Runtime 最核心的设计判断,不是选择了哪个模型 SDK,也不是把执行逻辑拆成了多少层,而是:
|
||
|
||
> **Runtime Event Log 才是 Agent 交互的语义事实源。系统在某一时刻的状态,是这段有序日志经过某种投影之后的结果。**
|
||
|
||
可以把它写成一个简单的关系:
|
||
|
||
```text
|
||
State(t) = Project(RuntimeEvents[0..t], policy, runtime configuration)
|
||
```
|
||
|
||
同一段日志可以被不同消费者解释成不同状态:
|
||
|
||
- Model History Projector 得到下一次模型调用需要看到的 messages;
|
||
- Runtime Read Model 得到 UI 需要展示的对话、工具活动和 Turn 状态;
|
||
- Terminal Fact Classifier 得到一次 Run 的最终结果;
|
||
- Recovery 逻辑判断进程退出前哪些事实已经 durable;
|
||
- Context Budget 与 compaction 策略得到一个更小、但保留关键语义的工作上下文;
|
||
- 未来的调试器可以把读取位置停在任意事件边界,观察当时的 Agent 状态空间。
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
L["Runtime Event Log\nordered semantic facts"]
|
||
L --> M["Model-history projection"]
|
||
L --> U["UI / Session projection"]
|
||
L --> T["Terminal fact / Run state"]
|
||
L --> R["Recovery and repair"]
|
||
L --> C["Context selection / compaction"]
|
||
M --> N["Next model request"]
|
||
```
|
||
|
||
这张图从中间开始读:`Runtime Event Log` 是稳定事实,其余节点是可以演进、重建或替换的派生视图。它刻意省略了事件生产路径;事件由哪些组件产生,会在后文解释。
|
||
|
||
这与普通 application log 有本质区别。普通日志通常是在业务执行之后描述“代码做过什么”,主要供人排障;RuntimeEvent 本身就是业务语义的一部分。用户消息、模型回复、thinking、function call、function response、权限动作、usage 和 terminal status 都以强类型事实进入日志。删除这份日志,系统就失去了可靠重建交互状态的基础。
|
||
|
||
## 一条 RuntimeEvent 保存了什么
|
||
|
||
`RuntimeEvent` 不只是 `role + text`。它把一条事实拆成几组正交信息:
|
||
|
||
| 维度 | 关键字段 | 意义 |
|
||
|---|---|---|
|
||
| Identity | `sessionId`, `invocationId`, `runId`, `turnId`, `branch` | 这条事实属于哪段会话、调用、执行尝试和分支 |
|
||
| Ordering | `id`, `ts` 与 ledger 顺序 | 这条事实在因果历史中的位置 |
|
||
| Source | `role`, `author` | 它在模型历史中扮演什么角色,由谁产生 |
|
||
| Content | text, thinking, function call/response, error | AI 交互本身的语义内容 |
|
||
| Actions | state delta, permission, artifact, usage, end invocation | 它要求 Runtime 怎样改变控制状态或记录副作用 |
|
||
| Correlation | tool call、provider event、step 与 artifact refs | 怎样把跨系统的同一件事重新配对 |
|
||
| Lifecycle | `partial`, `status` | 它是可替换的流式片段,还是持久事实或终止事实 |
|
||
|
||
这种结构保留的不是 UI 已经格式化好的聊天文本,而是模型交互的原始语义。尤其是:
|
||
|
||
- thinking 可以带 provider 需要的 signature;
|
||
- tool call 与 tool result 通过稳定 ID 配对;
|
||
- tool call 还能指向它所属的 assistant step;
|
||
- sandbox boundary 请求与决定是 action,而不是一段伪装成聊天的文字;
|
||
- terminal event 明确关闭一次 Invocation,而不是靠“最后一条消息看起来像回答”来猜测。
|
||
|
||
因此,模型历史不需要从 UI transcript 反向解析。它可以从 RuntimeEvent 中选择 non-partial、model-visible 的事件,保持顺序,再根据 provider 能力物化成 text-only 或 provider-native messages。UI 同样不需要成为事实源,它只是另一种 projection。
|
||
|
||
## “回放状态空间”究竟意味着什么
|
||
|
||
这里的 replay 有三个层次,需要精确区分。
|
||
|
||
### 语义回放:当前已经成立
|
||
|
||
给定 RuntimeEvent ledger,Maka 可以重建用户/模型文本、thinking、工具调用与结果、权限动作、usage 和 terminal fact。下一轮模型历史与 completed Session read model 都已经优先从这份 ledger 构造。
|
||
|
||
这意味着我们能回答:在某个事件边界之前,模型已经看到了哪些交互?它调用过什么工具?工具返回了什么?哪些权限被请求或决定?一次 Invocation 是否已经结束?
|
||
|
||
### Provider-native 回放:有能力门控
|
||
|
||
不同 provider 对 tool history 和 signed thinking 的要求不同。Maka 不会把所有事件盲目塞回模型,而是先建立 replay plan:检查 partial、tool call/result 配对、step ID、thinking signature 和 provider 支持,再决定走 provider-native、text-only,还是明确降级。
|
||
|
||
这一步很重要。可回放不等于“把 JSONL 原样发给任何模型”;它意味着保留足够丰富的事实,让投影器能够为具体 provider 生成合法的历史,同时显式报告语义损失。
|
||
|
||
### Bit-exact wire replay:不能只靠 message log 宣称
|
||
|
||
RuntimeEvent 保存了 AI 交互的 canonical message semantics,但当前并不等于完整快照每次 provider HTTP 请求的原始字节。System prompt、工具 schema、provider options、模型实现版本以及 context selection/compaction policy 仍参与最终 request 的生成;当前系统记录了其中一些 identity、diagnostic 和 hash,但没有把整个 wire request 都复制进 RuntimeEvent ledger。
|
||
|
||
因此,本章所说的“状态空间回放”首先是**交互语义与 Runtime 状态的可重建性**。如果未来要承诺 bit-exact deterministic replay,还需要对运行配置、prompt、tool catalog、投影策略和 provider request shape 做版本化或快照化。这不是削弱 Event Log 的价值,反而说明它提供了正确的基础:message facts 保持稳定,request materialization 可以独立演进。
|
||
|
||
## 两条思想来源
|
||
|
||
这个设计有两条明确的思想脉络。
|
||
|
||
第一条来自 Google ADK。ADK 把 Session 看作带有时间顺序 Events 的事实容器;Event 同时承载 content、author、invocation identity、partial 标记与 actions,Session state 通过事件中的 state delta 更新,模型工作上下文则从事件历史选择和转换得到。Maka 借鉴的关键不是字段长得相似,而是:**Session history 是事实,working context 是计算出来的 projection。**
|
||
|
||
第二条来自分布式数据系统中的 log-first 思想。数据库 WAL、replicated log、event sourcing 和 Kafka 共享一个重要直觉:不要把每个下游视图都当作独立真相;先保存有序、不可含糊的变化事实,再让消费者重建自己的状态。只要事件顺序和提交边界可信,缓存、索引、搜索视图乃至部分损坏的状态表都可以重新生成。
|
||
|
||
Maka 并不是在进程内实现了 Kafka,也没有声称 RuntimeEventStore 是一个分布式共识日志。借鉴的是更基础的设计原则:
|
||
|
||
> **Log is the source of truth; state is a materialized view.**
|
||
|
||
这一原则直接解释了后文最重要的 terminal invariant:Run header 不能凭自己宣布完成,它必须得到 terminal RuntimeEvent 的支持。
|
||
|
||
## 三种生命周期身份,加一个关联字段
|
||
|
||
理解主链之前,需要先分清三个经常被口语化混用的生命周期概念。
|
||
|
||
| 概念 | 它回答的问题 | 当前实现中的身份 |
|
||
|---|---|---|
|
||
| Session | 这些对话和运行属于哪段长期交互? | `sessionId` |
|
||
| Turn | 用户界面中的这一轮问答是哪一轮? | `turnId` |
|
||
| Run | 这一次具体执行尝试是谁?状态是什么? | `runId` / `AgentRun` |
|
||
|
||
RuntimeEvent 仍保留 `invocationId` 作为兼容与事件关联字段。生产主链把它绑定到 Run 身份;它不再对应单独的 Invocation 生命周期对象或 Runner 层。
|
||
|
||
这里最重要的判断是:**Turn 不是 Run,聊天消息也不是执行状态。** 一个用户可见的回合需要一个系统可追踪的执行封套;否则,系统只能知道“出现过一些消息”,却无法可靠回答“这次执行是否真正结束”。
|
||
|
||
## 围绕 Event Log 运转的执行主链
|
||
|
||
所有 hosted execution 路径共用下面这条 Runtime 主链:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["Caller"] --> B["SessionManager"]
|
||
B --> C["RuntimeKernel"]
|
||
C --> D["AgentRun"]
|
||
C --> G["AgentBackend"]
|
||
G --> F["SessionEvent Runtime mapper"]
|
||
F --> D
|
||
G --> H["ModelAdapter"]
|
||
G --> I["ToolRuntime"]
|
||
H --> J["Model Provider"]
|
||
I --> K["Tools / Workspace / Child Agents"]
|
||
```
|
||
|
||
从左向右读这张图:越靠左,越接近产品入口和长期 Session;越靠右,越接近一次 provider 请求和具体工具副作用。图中省略了存储投影和事件账本,它们会在后文单独解释。
|
||
|
||
这不是为了把一个函数拆成很多类。更准确地说,这些组件分担了 Event Log 的生产、规范化、提交和消费责任;每一层都在保护一种不同的稳定性:
|
||
|
||
### `SessionManager`:稳定的产品入口
|
||
|
||
`SessionManager.sendMessage()` 是外部调用者看到的门面。它现在很薄:读取和管理 Session 的公共能力保留在这里,而真正的执行直接委托给 `RuntimeKernel.startTurn()`。
|
||
|
||
这个边界让桌面端、CLI、Bot 和 Eval 调用者通过 Runtime Host 工作,不必理解 Run ledger、Flow 或 terminal fact。Runtime 内部可以在 Host 协议后演进。
|
||
|
||
### `RuntimeKernel`:活跃执行的控制面
|
||
|
||
`RuntimeKernel` 把一条 Session 请求组织成一次可运行的 Run。它负责:
|
||
|
||
- 创建 `AgentRun`;
|
||
- 创建或复用绑定到 Session 的 Backend;
|
||
- 注册活跃 Run,并维护 `turnId → runId` 映射;
|
||
- 把停止和权限响应路由到正在运行的 Backend;
|
||
- 驱动 Backend 事件流并映射 RuntimeEvent;
|
||
- 统一处理 abort 路由、单终态、终态后静默排空和缺失终态失败;
|
||
- 让 RuntimeEvent 落盘后,再把原有 `SessionEvent` 流交还调用者;
|
||
- 在 Backend 流收尾时确保 `AgentRun.finalize()` 被执行。
|
||
|
||
它是 orchestration boundary,而不是模型循环本身。Backend 不应该负责“这个 Session 目前有哪些活跃 Run”,产品入口也不应该负责“终止 RuntimeEvent 是否已经持久化”。这些跨层协调都收敛在 Kernel。
|
||
|
||
### `AgentRun`:一次执行的 Durable Envelope
|
||
|
||
`AgentRun` 让一次执行在持久世界里有身份和生命周期。开始运行时,它会:
|
||
|
||
1. 创建 `AgentRunHeader`,初始状态为 `created`;
|
||
2. 对顶层 Run 写入用户消息和 `running` Turn 投影;
|
||
3. 写入本轮初始用户 `RuntimeEvent`;
|
||
4. 锁定本 Session 的连接配置;
|
||
5. 确保 Backend 已创建并注册为活跃 Run;
|
||
6. 将 Run 标记为 `running`;
|
||
7. 从此前的 RuntimeEvent ledger 构造模型历史。
|
||
|
||
运行过程中,`AgentRun` 同时接收旧的 `SessionEvent` 与新的 `RuntimeEvent`,并把它们写入各自所属的投影或账本。结束时,它注销活跃 Run、收敛 Session/Turn 状态,并提交最终 Run 状态。
|
||
|
||
可以把 `AgentRun` 理解成一次执行的“耐久封套”:它不决定模型下一步调用哪个工具,但它保证这次执行是谁、发生了什么、最后以什么状态结束。
|
||
|
||
### `RuntimeKernel`:一个执行所有者,一套终态协议
|
||
|
||
Runtime 不再在 `AgentRun` 和 `AgentBackend` 之间插入通用 Runner/Flow 壳。`RuntimeKernel` 直接拥有生产主链的协议:
|
||
|
||
- `AgentRun.begin()` 在 Backend dispatch 前持久化初始用户 RuntimeEvent;
|
||
- `AgentBackend.send()` 前重新校验精确的活跃 Run;
|
||
- abort signal 路由到绑定具体 Backend generation 的 stop 函数;
|
||
- Backend 事件先映射并由 `AgentRun` 耐久接收,再作为 `SessionEvent` 暴露;
|
||
- 第一个被接受的 terminal fact 获胜,随后静默排空 Backend;
|
||
- Backend 抛错或流结束时缺少终态,都会收敛成结构化失败;
|
||
- durable continuation 消费一次性 start-admission proof,并且不伪造新的 user event。
|
||
|
||
### `SessionEvent Runtime mapper`:旧事件流与 Runtime 事实之间的桥
|
||
|
||
当前模型/工具循环仍然由 `AgentBackend.send()` 产生 renderer-facing `SessionEvent`。`session-event-runtime-mapper.ts` 把被接受的事件逐个映射成 canonical `RuntimeEvent`:
|
||
|
||
- 模型文本和 thinking 变成 model content;
|
||
- `tool_start` / `tool_result` 变成 function call / response;
|
||
- 权限请求和决定变成一等 runtime actions;
|
||
- token usage 变成 runtime action;
|
||
- error、abort 和 complete 变成明确的失败或终止事实。
|
||
|
||
mapper 是确定性的,不拥有 streaming、stop、dispose 或 admission。生命周期决策全部属于 `RuntimeKernel`;mapper 只负责 Backend 事件入口的词汇转换。
|
||
|
||
### `AgentBackend`:模型与工具循环真正发生的地方
|
||
|
||
对默认的 `AiSdkBackend` 来说,核心循环仍在 `send()` 内部。它会:
|
||
|
||
1. 解析模型并准备本轮可见的工具集合;
|
||
2. 从 RuntimeEvent 历史构造 provider messages,并应用上下文预算策略;
|
||
3. 组合 system prompt、当前用户输入和附件;
|
||
4. 通过 `ModelAdapter` 启动 AI SDK `streamText()`;
|
||
5. 读取 text、thinking、step boundary、finish reason 和 usage;
|
||
6. 让 AI SDK 在模型发起 tool call 时进入 `ToolRuntime`;
|
||
7. 将工具结果交回下一步模型请求;
|
||
8. 重复模型/工具 step,直到模型结束、达到上限、发生错误或被中止。
|
||
|
||
step 上限是可选的:`maxSteps` 为 `undefined` 时不设限,由模型自己决定何时收尾。若设置了上限而模型在上限处仍要求继续调用工具,Runtime 会保留已经产生的工具结果,并在没有最终文本时补充一条确定性的提示,让用户可以在新 Turn 中继续,而不是留下一个没有收尾文本的界面。
|
||
|
||
`ModelAdapter` 隔离 provider 与 AI SDK 差异,包括模型创建、stream 启动、chunk 归一化、usage 归一化和错误分类。`ToolRuntime` 则隔离工具执行的高风险部分,包括工具可用性防守、权限、超时/中止传递、重复失败拦截、工具输出、遥测和 artifact 记录。
|
||
|
||
## 模型循环是怎样向前推进的
|
||
|
||
下面这张时序图聚焦一次包含工具调用的正常运行。它刻意省略了部分遥测和兼容投影,只展示控制权如何在模型与工具之间转移。
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as User
|
||
participant K as RuntimeKernel
|
||
participant R as AgentRun
|
||
participant F as SessionEvent Runtime mapper
|
||
participant B as AiSdkBackend
|
||
participant M as Model Provider
|
||
participant T as ToolRuntime
|
||
|
||
U->>K: sendMessage(turnId, text)
|
||
K->>R: begin()
|
||
R-->>K: backend + history + initial RuntimeEvent
|
||
K->>B: send(BackendSendInput)
|
||
B->>M: streamText(messages, tools)
|
||
M-->>B: thinking / text / tool call
|
||
B->>T: execute(tool, args)
|
||
T-->>B: tool result
|
||
B->>M: next step with tool result
|
||
M-->>B: final text + finish
|
||
B-->>K: SessionEvents
|
||
K->>F: map accepted event
|
||
F-->>K: RuntimeEvent
|
||
K->>R: accept mapped fact
|
||
R->>R: commit terminal fact and finalize projections
|
||
```
|
||
|
||
AI SDK 的 step 是这个循环的自然节拍。Maka 会按 step 持久化 assistant text/thinking,而不是把整个 Turn 压成一条最终 assistant message。工具调用会携带对应的 step ID,使 thinking、文本和工具调用在 replay 时仍能重新组成原来的执行顺序。
|
||
|
||
工具运行期间,模型 provider 不会继续输出。`ToolRuntime` 会暂停模型流的 idle watchdog,因为这段安静是预期行为;具体工具仍然可以有自己的超时,而更外层的 Run 或评测系统继续充当最终 backstop。
|
||
|
||
## 权限不是弹窗,而是 Runtime 控制流
|
||
|
||
当一次工具调用越过当前沙箱边界时,工具不会静默失败,Runtime 也不会由 UI 自行弹一个对话框把执行挂起。工具返回一个带 `sandbox_boundary_required` 与具体 `expansion` 的失败结果;模型据此调用 `request_sandbox_boundary` 发起一次**边界扩张请求**,运行停在一个有身份的位置上等待答复。
|
||
|
||
等待期间,Session 投影为 `waiting_for_user`,但 Run 仍然保留自己的执行身份 —— 它没有结束,只是停住了。用户的决定通过 `RuntimeKernel.respondToSandboxBoundary()` 路由回当前 Backend;同一条路径上还有 `respondToUserQuestion()`,用于模型主动向用户提问的场景。两类请求在未答复期间都由 `RuntimeKernel` 登记为 active interaction,因此一个错过了实时事件的界面可以重新拉取待答项,而不会把运行晾在那里。
|
||
|
||
这个设计的关键是:权限不是 UI 自己暂停了一下。请求和决定都作为带类型的事实进入 Runtime 事实模型 —— 它们没有 `content`,而是带 `actions.stateDelta` 的系统事件。因此重放、诊断和恢复都能解释运行为什么停住,以及控制权如何回来。决定那条事实的 `role` 是 `system`、`author` 是 `user`:它在模型历史里属于系统泳道,但产生它的是人,这一对正交字段把这件事表达出来,而不需要把用户决定伪装成一条聊天消息。
|
||
|
||
`RuntimeKernel` 在映射和持久化前丢弃 `permission_request` / `permission_answer_ack` / `permission_closure_ack` / `permission_decision_ack` 这组遗留词汇:它们已被 sandbox boundary 事件取代,不能再成为活的运行时事实。
|
||
|
||
## 一份语义事实,两类辅助状态
|
||
|
||
Maka 当前同时维护三类持久数据。它们不是三个地位相同的“真相”,也不是重复保存同一份聊天。`RuntimeEventStore` 是 AI 交互的 canonical semantic log;另外两类存储承担产品投影与运行运维状态。
|
||
|
||
| 存储 | 主要内容 | 它最适合回答的问题 |
|
||
|---|---|---|
|
||
| `SessionStore` | 用户、assistant、工具和 turn-state 等 `StoredMessage` | UI 与兼容接口要展示什么?活跃流有哪些即时投影? |
|
||
| `AgentRunStore` | Run header 与 operational Run events | 这次 Run 何时开始、当前状态、在哪个模型或工具阶段失败? |
|
||
| `RuntimeEventStore` | canonical RuntimeEvent 与有界 partial snapshots | Agent 交互发生过哪些语义事实,其他状态应如何重建? |
|
||
|
||
当前实现由 SQLite 承载,而不是每个 Run 一个目录:`AgentRunStore` 与 `RuntimeEventStore` 都建立在同一份 operational state 数据库之上,RuntimeEvent 落在 `runtime_events` 表。顺序由该表的 `event_seq` 承担,并以 `(invocation_id, event_seq)` 唯一约束保证一次 Invocation 内序号不重复——“有序日志”在存储层就是这条约束。四个身份各占一列,因此“这个 Session 的这次 Run 的这个 Turn 发生了什么”是一次索引查询。
|
||
|
||
`AgentRunStore` 的事件更像 operational index:model stream 开始、tool started、边界请求、usage recorded。它帮助快速诊断和管理 Run,但不替代模型交互日志。`RuntimeEventStore` 的事件才是可重建的语义事实:用户内容、模型内容、function call/response、边界与提问动作,以及 terminal fact。
|
||
|
||
对于已经完成且 ledger 完整的 Run,读取与下一轮模型 replay 优先依赖 RuntimeEvent。`SessionStore` 仍然承担兼容投影和 in-flight 展示,不能简单删除;但它不再是 completed runtime 语义的唯一权威。
|
||
|
||
流式 text/thinking partial 不会被无限追加进账本。RuntimeEventStore 为可替换的流维护有界 partial snapshot,最终 non-partial 事件到达后覆盖其语义位置。这既保留崩溃时已经展示的部分输出,也避免 10,000 个 delta 变成 10,000 条长期账本记录。
|
||
|
||
## Log-first 的关键不变量:先有终止事实,再提交终止状态
|
||
|
||
Runtime 最容易出现的一类故障,是不同存储对“是否结束”给出不同答案。例如:
|
||
|
||
- Run header 写成 completed,但 RuntimeEvent ledger 没有 terminal event;
|
||
- 用户已经 stop,但迟到的 complete 又把 Session 写回 active;
|
||
- Backend 流耗尽,却从未说明它是成功还是失败;
|
||
- terminal event 已写入,但进程在更新 Run header 前崩溃。
|
||
|
||
Maka 当前保护的核心不变量是:
|
||
|
||
> 一个终止的 Run 必须有且只有一个有效 terminal RuntimeEvent;终止的 Run header 必须能够由这个 terminal fact 支撑。
|
||
|
||
因此,`AgentRun` 在提交 terminal Run header 前,先要求 terminal RuntimeEvent 成功落盘。没有终态的 Backend stream 会被合成为 `missing_terminal_event` 失败;重复终态由 Kernel 合并;状态不匹配、来自其他 Run 或标记为 partial 的 terminal event 都会被拒绝。
|
||
|
||
如果 terminal RuntimeEvent 已经存在,但 Run header 因中断仍是 `running`,read model 可以把 terminal event 作为更强事实来解释运行结果,并在恢复时修复 header。反过来,如果 header 声称已经结束却没有可信 terminal fact,系统不会盲目信任 header,而会保守地修复为 `missing_terminal_event` 失败。
|
||
|
||
这条不变量让恢复不必“猜模型当时准备做什么”。系统只需要判断哪些事实已经 durable,然后把各个投影收敛到同一个可解释终态。
|
||
|
||
## Stop、错误与崩溃如何收敛
|
||
|
||
### 用户停止
|
||
|
||
`RuntimeKernel.stopSession()` 会先把所有活跃 `AgentRun` 标记为 stopped,再调用 Backend 的 `stop()`。`AiSdkBackend` 会中止 provider stream、结束正在等待的 sandbox boundary 或用户提问,并产生 abort/complete 事件。即使 provider 随后发送迟到的 complete 或 error,RuntimeKernel 与 AgentRun 也不会允许它覆盖已经确定的 aborted 语义。停止来源,例如 renderer stop button,会进入 terminal fact 与 Run header,供诊断使用。
|
||
|
||
### Provider 或 Runtime 错误
|
||
|
||
错误首先被规范化为非终止 error content,随后由 failed terminal event 关闭 Run。`RuntimeKernel` 不允许后续的 completed event 掩盖之前已经观察到的错误。若 Backend 直接抛出异常或流没有终态,Kernel 会产生结构化失败,而不是留下悬空运行。
|
||
|
||
### 应用崩溃和启动恢复
|
||
|
||
启动恢复不会重新执行模型请求或工具副作用。它扫描非终止 Run 与 RuntimeEvent ledger,识别 stale model stream、tool tail、未答复的 interaction、损坏的 operational event 等情况,然后保守地提交失败或取消状态,并修复 Session/Turn 投影。
|
||
|
||
这是“状态修复”,不是 checkpoint resume:它保留已经产生的部分输出,把状态收敛到一个可解释的终态,但不会在进程重启后自动从某个工具调用的下一行继续执行。
|
||
|
||
继续执行是另一条路径。`safe_boundary_continuation` 从一个经过校验的安全边界接着跑——边界之前的事实全部可信,边界之后的不予采信。它同样不从被打断的那一行开始,区别在于它有一个可验证的起点,而崩溃收敛只是给一次执行下结论。
|
||
|
||
## 这套设计换来了什么,又付出了什么
|
||
|
||
### 得到的能力
|
||
|
||
- 产品入口不依赖具体 provider 或工具循环实现;
|
||
- 不同 Backend 通过 Kernel 共享 Run 与 terminal 语义;
|
||
- UI 事件与模型可重放事实被明确区分;
|
||
- 用户停止、权限和工具副作用进入可诊断的控制流;
|
||
- 崩溃后可以依据 durable facts 收敛状态;
|
||
- Runtime Host client、子 Agent 和调度器复用同一执行核心。
|
||
|
||
### 当前代价
|
||
|
||
- 迁移期同时存在 `SessionEvent`、`StoredMessage`、`RuntimeEvent` 和 operational Run events,事件映射的维护成本较高;
|
||
- `AiSdkBackend` 仍然很重,同时组织 history、context budget、tool availability、step loop、usage 与 telemetry;
|
||
- `SessionEvent Runtime mapper` 仍承担 legacy-to-canonical adapter 角色,而不是 Backend 原生产 canonical events;
|
||
- `SessionStore` 与 RuntimeEvent projection 需要在 active/in-flight 场景中协同;
|
||
- 启动恢复是确定性终结与修复,不是从任意位置热续跑。续跑走另一条路:`safe_boundary_continuation` 从一个经过校验的安全边界接着跑,Run header 上是 `continuationSource`,由 `RuntimeKernel` 完成准入和 dispatch;两者的区别见[第八章](./runtime-resume-architecture.zh-CN.md)。
|
||
|
||
这些不是应该隐藏的实现细节,而是当前架构的真实边界。未来拆分 Backend 或加入 checkpoint 时,首要目标不是减少文件行数,而是保持 request shape、工具可见性、事件顺序和 terminal invariant 不变。
|
||
|
||
## 代码阅读地图
|
||
|
||
建议按照下面的顺序阅读当前实现:
|
||
|
||
1. `packages/runtime/src/session-manager.ts`:公共入口与恢复入口。
|
||
2. `packages/runtime/src/runtime-kernel.ts`:Run/Backend 的活跃控制与主链组装。
|
||
3. `packages/runtime/src/agent-run.ts`:Durable lifecycle、历史构造和终态提交。
|
||
4. `packages/runtime/src/session-event-runtime-mapper.ts`:纯 `SessionEvent → RuntimeEvent` 映射。
|
||
5. `packages/runtime/src/ai-sdk-backend.ts`:AI SDK 模型/工具 step loop。
|
||
6. `packages/runtime/src/model-adapter.ts`:provider stream 适配。
|
||
7. `packages/runtime/src/tool-runtime.ts`:沙箱边界、工具执行和副作用边界。
|
||
8. `packages/core/src/runtime-event.ts`:canonical RuntimeEvent 契约。
|
||
9. `packages/core/src/agent-run.ts` 与 `packages/storage/src/agent-run-store.ts`:Run 与 RuntimeEvent 的账本契约与 SQLite 实现。
|
||
|
||
对应的关键测试集中在:
|
||
|
||
- `packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts`
|
||
- `packages/runtime/src/__tests__/session-manager.test.ts`
|
||
- `packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts`
|
||
- `packages/runtime/src/__tests__/runtime-ledger-repair.test.ts`
|
||
|
||
## 小结
|
||
|
||
Maka Runtime 的核心不在某一个类,也不只是 AI SDK 的多步工具循环。核心是一个可以保留并回放 Agent 交互状态空间的 Runtime Event Log;执行协议围绕这份日志产生事实、提交事实并构造派生状态:
|
||
|
||
```text
|
||
model/tool stepping engine
|
||
→ canonical RuntimeEvents
|
||
→ durable semantic log
|
||
→ model history / UI / Run state / recovery projections
|
||
```
|
||
|
||
`SessionManager` 稳住入口,`RuntimeKernel` 管理活跃执行,`AgentRun` 提交耐久事实,`SessionEvent Runtime mapper` 把 Backend 事件翻译成 canonical facts,而 `AiSdkBackend`、`ModelAdapter` 与 `ToolRuntime` 真正推进模型和工具循环。它们都围绕 Runtime Event Log 协作,而不是各自保存一份局部真相。
|
||
|
||
这套结构最终保护的是一件很朴素的事:无论一次 Agent 工作经历多少模型 step、工具副作用、等待用户答复和异常,Maka 都要先忠实记录发生过什么。只要这份有序事实仍在,系统就能重新构造当时的交互状态,生成新的视图,并让下一轮从可信历史继续。
|
||
|
||
## 延伸阅读
|
||
|
||
- [Google ADK: Architecting an efficient context-aware multi-agent framework](https://developers.googleblog.com/architecting-efficient-context-aware-multi-agent-framework-for-production/)
|
||
- [Google ADK Python: Runner lifecycle and event persistence](https://github.com/google/adk-python/blob/main/AGENTS.md)
|
||
- [Apache Kafka Design: replicated logs, state machines, and event sourcing](https://kafka.apache.org/36/design/design/)
|