- 人工智能
- AI Agent
- Agent 框架
- 后端
- 工具调用
【免费下载链接】eve
The Open Framework for Building Agents
导读
本文基于 eve 开源仓库中的协议设计文档 research/delta-text-streams.md,深入讲解 eve 会话消息流(Message Stream)如何将message.appended、reasoning.appended等追加事件从"增量 + 全文快照"的累积快照模型,演进为只持久化纯增量的 v25/v26 delta 协议,从而消除持久化字节随生成文本二次方增长的问题。读完本文,你将掌握新协议的完整事件契约、各消费方(默认 reducer、Chat SDK 通道、Slack、TUI、Next.js 追踪查看器、ACP/subagent)的迁移方案、重试与重连语义,以及仓库中从事件发射到客户端归一化的完整源码链路。
背景:累积快照为何导致持久化字节二次方增长
在旧的流协议(v21–v24)中,message.appended与reasoning.appended事件同时携带两样东西:本次模型产出的最新增量,以及该 block 到目前为止累计的完整文本。
// v24 时代的 message.appended 形状(legacy) data: { messageDelta: string; // 本次增量 messageSoFar: string; // 到目前为止的完整累积文本 sequence: number; stepIndex: number; turnId: string; }问题在于:messageSoFar是随生成进程不断增长的完整快照。设生成文本长度为 N,每次追加事件都要把当前累计的全部文本写入持久化流,N 次追加合计写入的字节数约为1 + 2 + … + N = O(N²),即持久化字节随生成文本长度呈二次方增长。即使message.completed与reasoning.completed事件已经会在 block 结束时持久化最终完整值,追加阶段重复写快照仍然造成了大量冗余存储。
设计文档给出的方案很直接:每个追加事件只存增量本身;需要渐进文本的消费方按流顺序自行累加增量;每个已定稿 block 的权威值仍由 completed 事件携带。
新协议契约:纯增量事件结构
新的 stream contract 在 packages/eve/src/protocol/message.ts 中有完整的类型定义,追加事件只保留一个文本字段与既有的流坐标(sequence、stepIndex、turnId):
type MessageAppendedData = { messageDelta: string; // 既有流坐标保持不变:sequence / stepIndex / turnId }; type ReasoningAppendedData = { reasoningDelta: string; // 既有流坐标保持不变 };对应的具体事件接口(源码 message.ts#L487-L525):
export interface MessageAppendedStreamEvent { data: { messageDelta: string; sequence: number; stepIndex: number; turnId: string; }; type: "message.appended"; } export interface ReasoningAppendedStreamEvent { data: { reasoningDelta: string; sequence: number; stepIndex: number; turnId: string; }; type: "reasoning.appended"; }工具调用输入同样改为纯增量契约(message.ts#L501-L511),旧版的inputTextOffset被移除:
export interface ActionInputAppendedStreamEvent { data: { callId: string; inputTextDelta: string; // 只携带增量,不再携带 offset sequence: number; stepIndex: number; toolName: string; turnId: string; }; type: "action.input.appended"; }增量合并(Coalescing)
设计文档同时允许一种优化:当一次持久化写入尚在进行中时,相邻的增量可以在满足以下条件时被合并(coalesce):
- 事件类型相同(同为
message.appended或同为reasoning.appended); - 流坐标相同(
turnId、stepIndex、sequence一致); - 工具输入追加事件的
callId匹配。
合并只影响写入的批次数,不改变最终产生的文本与事件顺序;completed 事件始终携带定稿后的完整 block 值,因此合并对下游语义无影响。
这是一次 v25 的破坏性变更
文档明确标注:这是stream-version 25 破坏性变更,具体变化为:
messageDelta取代原来的messageDelta + messageSoFar二元组;reasoningDelta取代原来的reasoningDelta + reasoningSoFar二元组;- 流式工具输入移除
inputTextOffset,改用与消息/推理一致的纯增量契约。
在仓库现状中,当前协议版本已推进到EVE_MESSAGE_STREAM_VERSION = "26"(见 message.ts#L31),且 v25 与 v26 采用相同的纯增量追加格式(见 message-version.ts#L47-L57 中 v25、v26 都指向ActionInputAppendedStreamEvent | MessageAppendedStreamEvent | ReasoningAppendedStreamEvent)。协议测试同样固定了这一事实(message.test.ts#L33-L35 断言版本为"26")。
消费方规则:current += delta
新契约下,所有需要渐进文本的消费方遵循同一句规则:
current += delta;只要重放(replay)在相关增量开始之前发起,重放得到的累积状态就与实时投递完全一致。completed 事件到达时,消费方用其中的权威全文替换掉此前由增量累加得到的临时文本。
消费方迁移清单
设计文档给出了一张完整的消费方迁移表,下面逐项继承并补充说明:
| 消费方 | 累积状态(Cumulative state) | 投影方式(Projection) |
|---|---|---|
| 默认消息 reducer | 最新的流式文本/推理/工具输入 part | 追加增量;completed 事件到达时用权威值替换临时文本。 |
| Chat SDK 通道 | 以通道状态身份为 key 的内存文本 | 先发布第一条增量消息,随后用累积文本反复编辑,最终由message.completed定稿。 |
| Slack 通道 | 以通道状态身份为 key 的内存推理文本 | 保留渐进推理状态与节流(throttling)行为。 |
| TUI | 按 turn、step、类型和 generation 组织文本 | 发射增量;若出现与增量分歧的 completed 事件,则渲染为一条独立的 canonical generation。 |
| Next.js 追踪查看器 | 按 trace step 组织文本 | 直接消费公开的MessageStreamEvent联合类型,因此流 schema 变更会使其编译失败而非运行时出错。 |
| ACP 与 subagent pump | 无(不累积) | 原样转发每条原始增量,无需迁移。 |
| 自定义(authored)通道与 hooks | 消费方自行管理 | 需要累积文本时自行追加增量,或逐块处理每个 chunk。 |
| 已发布扩展 | 编译后的能力契约(capability contracts) | 重新构建并发布 manifest 中能力 epoch 因本次流事件变更而推进的包。 |
重试与重连语义
这是本次改造中最容易踩坑的部分,设计文档做了非常清晰的边界划分。
Provider 重试:attempt identity 缺失带来的可见性差异
provider 重试会复用原来的 turn 坐标与 step 坐标。由于追加协议本身不携带 attempt 身份(attempt identity),存在以下可见性差异:
- 如果失败的那次尝试已经发射过部分文本,纯增量消费者会先看到这部分文本,再看到成功尝试的增量——因为追加协议无法区分两次尝试;
- 如果失败尝试只发射过增量(未定稿),默认 reducer 与 Chat SDK 通道会在 completed 事件到达时用权威值替换临时文本;
- TUI 无法收回已经交给渲染器的文本,因此遇到与累积状态分歧的 completed 事件时,会将其渲染为独立的 generation(如迁移表中所述);
- 更重要的是:来自后来失败尝试的、已经完成的 block 仍然是持久的——要收回它们就必须引入 attempt identity,这正是当前协议刻意不做的事。
重连:累积器与游标必须成对出现
- 同时持有旧累积器(prior accumulator)与流游标(stream cursor)的消费者,重连后可以无缝继续;
- 只保留了游标的消费者,无法从后面的增量反推出前面丢失的文本——它必须重放更早的事件,或等待 completed 事件拿到权威全文。这正是"移除累积快照所丧失的能力";
- 数字字符位置(numeric character positions)并不能解决这种缺失内容问题,因此协议不携带字符位置字段。
字符连续性的边界
受支持的客户端依赖持久化事件游标做有序投递。delta 契约不校验字符连续性:如果原始消费者漏掉了一个事件,就会得到不完整的累积器;数字位置虽然能检测出部分缺口,却无法恢复缺失的文本。这是纯增量协议在"更小的持久化开销"与"更强的自校验能力"之间的有意取舍。
跨版本归一化:客户端与服务端各做一遍
客户端在每次连接时都会读取x-eve-stream-version流版本头(仓库中定义于 message.ts#L28,EVE_STREAM_VERSION_HEADER)。v21–v24 的累积式 message/reasoning 追加、以及 v24 基于 offset 的工具输入追加,都会被归一化为 v25 delta-only 契约,之后才交给 reducer——包括重连跨越了不同部署版本的情况。
服务端侧同样具备归一化能力:当重放由更早部署持久化的事件时,当前服务端会执行相同的归一化。关键在于:缺失、不受支持或形状不一致的版本会直接失败,而不是被强行 cast 成当前事件联合类型。
通道累积器为什么放在 WeakMap 里
内置通道的累积器位于以内存中通道状态身份为 key 的 weak map中,而不是作为持久化通道状态的属性存在(Chat SDK 通道的实现在 chatSdkChannel.ts#L57 定义了streamTextByState = new WeakMap<ChatSdkChannelState, string>())。
原因在于:运行时会在每个 durable turn-step 边界序列化channel.state。如果把累积文本放进去,即使它不会像旧的messageSoFar那样每次追加事件都重复写入,也会在每次 turn-step 边界引入一次不必要的快照写入——与本次改造的目标背道而驰。
验证策略
设计文档列出六条验证方向,仓库中均已落地为可运行的测试与类型约束:
- 协议与发射测试:断言纯 delta 形状与合并边界(见 message.test.ts 与 harness/emission.test.ts);
- 默认 reducer 测试:覆盖累积逻辑与被中断尝试之后的 canonical 完成行为(message-reducer.test.ts);
- Chat SDK、Slack、TUI 回归:覆盖各自的累积投影与 completed 事件行为(对应 chatSdkChannel.test.ts、slackChannel.test.ts、cli/dev/tui 下的测试);
- 重连回归:以 v24 累积追加开始、由 v25 延续完成同一个 block(见 message.test.ts#L37-L84 的跨版本归一化断言);
- Next.js 追踪:别名公开事件联合类型,使过期的追加字段读取成为编译期错误;
- 扩展契约报告:识别扩展作者必须重新构建的每个能力 epoch(对应仓库 scripts/extension-contracts 的兼容性报告机制)。
源码级纵深:从模型流到持久化事件的完整链路
发射端:AI SDK fullStream 消费与 delta 事件生成
服务端发射逻辑位于 packages/eve/src/harness/emission.ts。它消费 AI SDK 的fullStream,对每个 part 分发到对应的发射路径(emission.ts#L321-L362):
case "reasoning-delta": await providerActionBatch.flush(); currentReasoning += part.text; await emitFn( createReasoningAppendedEvent({ reasoningDelta: part.text, // 只发射增量 sequence: state.sequence, stepIndex: state.stepIndex, turnId: state.turnId, }), ); break; case "text-delta": await providerActionBatch.flush(); // 推理块结束、文本开始前,先 flush 累积的 reasoning if (currentReasoning.trim().length > 0) { await emitFn(createReasoningCompletedEvent({ reasoning: currentReasoning, ... })); currentReasoning = ""; } currentMessage += part.text; await emitFn( createMessageAppendedEvent({ messageDelta: part.text, // 只发射增量 sequence: state.sequence, stepIndex: state.stepIndex, turnId: state.turnId, }), ); break;注意currentReasoning/currentMessage在发射端仍然存在——它只是用于在文本开始时 flush 推理块、以及构造 completed 事件,并不会随追加事件写入持久化流。事件构造函数createMessageAppendedEvent与createReasoningAppendedEvent在 message.ts#L1554-L1589 中只接收 delta 字段。
客户端解析:按流版本头归一化
客户端通过 packages/eve/src/client/ndjson.ts 的readNdjsonStream读取 NDJSON 事件流,每次调用都携带从响应头解析出的streamVersion(ndjson.ts#L45-L53);每一行 JSON 经过parseMessageStreamEvent调用normalizeMessageStreamEvent(version, value)归一化后才上抛(ndjson.ts#L130-L135)。
归一化实现:形状检测 + 严格校验
核心归一化逻辑在 packages/eve/src/protocol/message-version.ts:
normalizeMessageStreamEvent(version, event)(message-version.ts#L82-L115)按声明版本分发:v21–v24 走 legacy 归一化,v25/v26 走纯 delta 校验;normalizePersistedMessageStreamEvent(event)(message-version.ts#L117-L135)不依赖版本号,而是按事件形状检测:只要message.appended里存在messageSoFar、reasoning.appended里存在reasoningSoFar、或action.input.appended里存在inputTextOffset,就判定为 legacy 事件并归一化——这正好对应设计文档中"服务端重放旧部署持久化事件时执行相同归一化"的约定;normalizeLegacyMessageStreamEvent(message-version.ts#L137-L199)在剥离快照字段前会执行assertLegacyAppendSnapshot校验:要求snapshot.length - delta.length >= 0且snapshot.slice(offset) === delta,即快照必须以本次增量结尾,否则抛TypeError(message-version.ts#L287-L298);- 对于 v25/v26 的纯增量事件,
validateDeltaMessageStreamEvent会断言 delta 为字符串,并用assertUnsupportedAppendField拒绝任何残留的messageSoFar、reasoningSoFar、inputTextOffset等字段(message-version.ts#L233-L271),实现"形状不一致即失败而非 cast"的设计意图。
协议测试对这套逻辑给出了直接证据:message.test.ts#L37-L84 遍历 v21–v24,断言带messageSoFar/reasoningSoFar的 legacy 事件经normalizeMessageStreamEvent与normalizePersistedMessageStreamEvent后只剩 delta 字段;message.test.ts#L86-L113 覆盖 v24 工具输入追加剥离inputTextOffset的场景。
默认 reducer:append 与 complete 两态投影
默认消息 reducer 在 packages/eve/src/client/message-reducer.ts 中实现了"追加 + 替换"的投影:
reasoning.appended→ 对当前 step 的 reasoning part 执行kind: "append", delta: event.data.reasoningDelta(message-reducer.ts#L145-L154);reasoning.completed→ 执行kind: "complete", text: event.data.reasoning,用权威值替换(message-reducer.ts#L156-L165);message.appended→ 对 text part 执行kind: "append", delta: event.data.messageDelta(message-reducer.ts#L358-L367);message.completed→ 执行kind: "complete", text: event.data.message(message-reducer.ts#L369-L378);action.input.appended→inputText = (existing?.inputText ?? "") + event.data.inputTextDelta(message-reducer.ts#L167-L185)。
会话级投影(session-projection.ts#L311-L324)也顺应新契约:message.appended通过typed.data.messageDelta.length > 0判断是否有输出,message.completed通过typed.data.message?.length ?? 0判断——两者都只读取 delta 或最终值,不再依赖快照字段。
Chat SDK 通道:首条发布 + 节流编辑 + completed 定稿
Chat SDK 通道在 packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts 中完整实现了迁移表描述的投影:
message.appended时从streamTextByStateweak map 取出累积文本、追加 delta 后写回(chatSdkChannel.ts#L457-L465);- 若当前 step 还没有锚点消息,则直接发布第一条增量文本并记录
anchorMessageId;已有锚点则按streamingEditIntervalMs节流地editMessage更新(chatSdkChannel.ts#L466-L484); message.completed时调用finalizeStreamedMessage,用权威全文编辑锚点消息(或在不支持编辑/未流式时直接发布),完成定稿(chatSdkChannel.ts#L493-L515、chatSdkChannel.ts#L567-L590)。
这也印证了设计文档中"累积器放 weak map 而非 channel.state"的决策:累积文本只在内存中存在,随通道状态的生命周期自动回收,绝不被序列化进持久化的 channel state。
小结
delta 文本流改造是 eve 流协议在持久化成本与消费方复杂度之间的一次明确取舍:服务端只落盘增量,把"累积"的责任交给每一个按序消费事件的投影方,把"权威值"统一收敛到 completed 事件;通过x-eve-stream-version头、客户端/服务端双侧归一化与严格的形状校验,保证 v21–v24 的历史会话与 v25/v26 的新部署可以无缝共存与重放。对协议实现者而言,本仓库的 message.ts、message-version.ts 与 message.test.ts 构成了完整可参考的契约、归一化与测试范本。
- 人工智能
- AI Agent
- Agent 框架
- 后端
- 工具调用
【免费下载链接】eve
The Open Framework for Building Agents
相关推荐
foobox-cn 完整使用指南:10 分钟把 foobar2000 改成现代播放器
foobox cn 完整使用指南:10 分钟把 foobar2000 改成现代播放器 周五晚上你点开 foobar2000,面对的还是一套灰白窗口加密密麻麻的菜
桌面应用音视频eve chat 模板开发指南:守住 Eve 协议与持久会话的不变量(SKILL 深度解读)
eve chat 模板开发指南:守住 Eve 协议与持久会话的不变量(SKILL 深度解读) 本篇基于 apps/templates/eve chat temp
人工智能AI AgentAgent 框架后端工具调用在 OpenUI 中接入 Vercel Eve Agent:手写 ChatLLM 与 Eve 原生会话协议的完整接线方案
在 OpenUI 中接入 Vercel Eve Agent:手写 ChatLLM 与 Eve 原生会话协议的完整接线方案 本指南以仓库内 examples/ag
人工智能AI 应用前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考