news 2026/10/15 10:06:47

eve 会话流的 Delta 化改造:从二次方快照到纯增量追加的协议演进

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
eve 会话流的 Delta 化改造:从二次方快照到纯增量追加的协议演进
  • 人工智能
  • AI Agent
  • Agent 框架
  • 后端
  • 工具调用

【免费下载链接】eve

The Open Framework for Building Agents

项目地址:https://gitcode.com/gh_mirrors/eve28/eve
点击查看免费下载

导读

本文基于 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 中完整实现了迁移表描述的投影:

  1. message.appended时从streamTextByStateweak map 取出累积文本、追加 delta 后写回(chatSdkChannel.ts#L457-L465);
  2. 若当前 step 还没有锚点消息,则直接发布第一条增量文本并记录anchorMessageId;已有锚点则按streamingEditIntervalMs节流地editMessage更新(chatSdkChannel.ts#L466-L484);
  3. 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

项目地址:https://gitcode.com/gh_mirrors/eve28/eve
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 10:06:43

Android手势识别开发实战与性能优化指南

1. 项目概述在移动应用开发领域&#xff0c;手势交互已经成为提升用户体验的关键要素。作为一名在Android平台深耕多年的开发者&#xff0c;我见证了从早期简单的点击事件到如今复杂手势识别体系的完整演进过程。这份指南将系统性地梳理Android手势识别的技术脉络&#xff0c;从…

作者头像 李华
网站建设 2026/10/15 10:05:33

RV1108数据手册深度解读:硬件设计关键参数与实测验证指南

简介&#xff1a;本资源为Rockchip RV1108芯片官方数据手册&#xff08;Datasheet&#xff09;V1.5正式版&#xff0c;发布于2019年11月7日&#xff0c;面向嵌入式软硬件工程师、IoT设备开发者及SoC级系统设计人员&#xff0c;用于芯片选型评估、原理图设计、PCB布局与底层驱动…

作者头像 李华
网站建设 2026/10/15 10:05:18

C/C++排序算法实战:指针、内存与稳定性避坑指南

1. 这不是教科书笔记&#xff0c;是我在写C/C排序代码时踩出来的坑“数据结构学习笔记之C/C实现常用排序算法”——这个标题听起来像学生交的作业&#xff0c;但如果你真在Linux终端里敲过冒泡排序的嵌套循环、在VS调试窗口里盯着快排的partition函数单步跳转、为堆排序的下标计…

作者头像 李华
网站建设 2026/10/15 10:04:49

Writeup 4 ByteMe CTF 2026 Broken Soul Vault

Writeup 4 ByteMe CTF 2026 Broken Soul Vault 分类&#xff1a; Forensics 难度&#xff1a; Easy | 分数&#xff1a; 100 附件&#xff1a; metadata.bin.txt&#xff08;87 字节&#xff09;、vault.log&#xff08;133 字节&#xff09; 作者&#xff1a; Pratik Jadhav 题…

作者头像 李华