本篇文章是《从零开发一个 Coding Agent》系列第四篇。在上一篇中,我们实现了一个通用的 EventStream 通道:Provider 负责调用 push() 写入事件,Agent 使用 for await...of 读取事件。
但是,EventStream 只负责运输数据,并不知道事件出现的顺序是否正确。例如下面这个对象完全符合 StreamEvent 的 TypeScript 类型:
{ type: "text_delta", contentIndex: 0, delta: "你好" }
如果它前面没有出现 start 和 text_start,它在协议上仍然是非法事件。TypeScript 只能检查单个对象的字段,不能记住这条流之前发生过什么。因此,我们还需要一个有记忆的校验器,也就是状态机(State Machine)。
完成后,事件进入 EventStream 之前会先经过顺序校验:
Provider -> EventStream.push(event) -> StreamEventValidator.accept(event) -> Agent
合法事件会继续进入队列;非法事件会抛出 StreamSequenceError,并让事件生产者、异步迭代器和 result() 得到同一个错误。
事件流的状态
先看一条合法的文本响应:
start
-> text_start(0)
-> text_delta(0, "你")
-> text_delta(0, "好")
-> text_end(0, "你好")
-> done
整条响应有三个阶段:
stateDiagram-v2
[*] --> idle
idle --> streaming: start
streaming --> streaming: 内容块事件
streaming --> terminal: done 或 error
terminal --> [*]
idle:还没有收到start。streaming:已经开始,可以接收内容块或终止事件。terminal:已经收到done或error,不能再接收事件。
除了整条流的阶段,我们还要记录当前正在拼接的内容块。一个 AssistantMessage 可以包含文本、思考过程和工具调用:
content[0] -> text
content[1] -> thinking
content[2] -> tool_call
因此,状态机还要保证:
- 同一时刻最多有一个未结束的内容块。
contentIndex从 0 开始连续递增。- delta 必须属于当前活动块。
- end 携带的完整内容必须等于所有 delta 的累积结果。
- 一个块完全结束后,才能开始下一个索引。
定义状态和顺序错误
在 packages/ai/src/utils 目录中新建 validation.ts,先导入公共事件与消息内容类型:
import type { AssistantContent, StreamEvent } from "../types.ts";
type StreamPhase = "idle" | "streaming" | "terminal";
然后定义活动内容块:
type ActiveBlock =
| { kind: "text"; contentIndex: number; value: string }
| { kind: "thinking"; contentIndex: number; value: string }
| {
kind: "tool_call";
contentIndex: number;
id: string;
name: string;
argumentsJson: string;
sawArgumentsDelta: boolean;
};
文本和思考块只需要保存累积字符串。工具调用除了索引,还需要保存工具的 id、名称和分段到达的 JSON 字符串。
sawArgumentsDelta 不能直接用 argumentsJson.length > 0 代替。因为 Provider 可能真的推送一个空字符串 delta;“没有收到 delta”和“收到过空 delta”是两种不同的协议状态。
接着定义整条流的内部状态:
interface SequenceState {
phase: StreamPhase;
nextContentIndex: number;
activeBlock?: ActiveBlock;
completedContent: AssistantContent[];
}
这里四个字段分别表示:
| 字段 | 作用 |
|---|---|
phase | 当前处于开始前、流式传输中,还是已经终止 |
nextContentIndex | 下一个内容块必须使用的索引 |
activeBlock | 当前正在累积的内容块 |
completedContent | 已经完整结束的消息内容,用来核对最终消息 |
普通的 Error 只能告诉我们“出错了”,排查 Provider 时还需要知道哪个事件在哪个阶段出错。因此定义一个可识别的错误类型:
export class StreamSequenceError extends Error {
readonly eventType: StreamEvent["type"];
readonly phase: StreamPhase;
constructor(eventType: StreamEvent["type"], phase: StreamPhase, detail: string) {
super(`Invalid stream event "${eventType}" in phase "${phase}": ${detail}`);
this.name = "StreamSequenceError";
this.eventType = eventType;
this.phase = phase;
}
}
export interface StreamEventValidator {
accept(event: StreamEvent): void;
}
accept() 没有返回值。事件合法时直接返回,非法时同步抛错。这样 EventStream.push() 可以在把事件交给消费者之前完成校验。
再加入两个辅助函数,统一错误格式并检查新内容块的索引:
function reject(event: StreamEvent, state: SequenceState, detail: string): never {
throw new StreamSequenceError(event.type, state.phase, detail);
}
function assertStartIndex(event: StreamEvent, state: SequenceState, received: number): void {
if (received !== state.nextContentIndex) {
reject(event, state, `expected contentIndex ${state.nextContentIndex}, received ${received}`);
}
}
reject() 的返回类型是 never,表示函数一定会终止当前控制流。TypeScript 因此能够在调用 reject() 后继续缩小联合类型。
校验 JSON 数据
工具参数的公共类型是 Record<string, unknown>,但并不是所有 JavaScript 值都能写入 JSON。例如 undefined、函数、Date、Map 和 Infinity 都不属于合法 JSON 数据。
先定义 JSON 可以表达的递归类型:
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
再加入运行时检查:
function isJsonValue(value: unknown): value is JsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") {
return true;
}
if (typeof value === "number") {
return Number.isFinite(value);
}
if (Array.isArray(value)) {
return value.every(isJsonValue);
}
if (typeof value === "object") {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
return false;
}
return Object.values(value as Record<string, unknown>).every(isJsonValue);
}
return false;
}
function isJsonObject(value: unknown): value is Record<string, JsonValue> {
return value !== null && typeof value === "object" && !Array.isArray(value) && isJsonValue(value);
}
isJsonValue() 会递归检查数组和普通对象。数值额外使用 Number.isFinite(),排除 JSON 无法准确表示的 NaN、Infinity 和 -Infinity。
工具参数还必须以对象为根,所以 isJsonObject() 会排除 null 和数组。这里检查的是“它是不是合法 JSON 对象”,并不检查 { path: 123 } 是否符合某个具体工具的参数 schema;具体工具参数校验属于后续 Task 03c。
工具参数在 delta 中是字符串,在 end 事件中已经是对象。JSON.parse() 每次都会创建新对象,因此不能使用 === 比较引用,需要按结构和值进行深比较:
function jsonDeepEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) {
return true;
}
if (Array.isArray(left) || Array.isArray(right)) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((value, index) => jsonDeepEqual(value, right[index]))
);
}
if (left === null || right === null || typeof left !== "object" || typeof right !== "object") {
return false;
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord).sort();
const rightKeys = Object.keys(rightRecord).sort();
return (
leftKeys.length === rightKeys.length &&
leftKeys.every((key, index) => key === rightKeys[index] && jsonDeepEqual(leftRecord[key], rightRecord[key]))
);
}
对象的 key 会先排序,所以 { path: "A", line: 1 } 和 { line: 1, path: "A" } 会被视为相等。
创建校验器
接下来实现 createStreamEventValidator()。它不是一个全局单例,而是每调用一次都创建一份独立状态:
export function createStreamEventValidator(): StreamEventValidator {
const state: SequenceState = {
phase: "idle",
nextContentIndex: 0,
completedContent: [],
};
return {
accept(event) {
if (state.phase === "idle") {
if (event.type !== "start") {
reject(event, state, 'expected "start" as the first event');
}
state.phase = "streaming";
return;
}
if (state.phase === "terminal") {
reject(event, state, "no events are allowed after a terminal event");
}
if (event.type === "start") {
reject(event, state, '"start" may appear only once');
}
switch (event.type) {
// 后面依次加入各类事件分支
}
},
};
}
闭包中的 state 只属于这一个 validator。两个并发模型请求分别调用工厂函数后,不会共享 phase、索引或内容。
进入 switch 前先处理三个全局规则:
- 第一项必须是
start。 start只能出现一次。- 进入
terminal后不允许再出现任何事件。
下面的代码都加入这个 switch (event.type) 中。
实现文本和思考内容块
文本和思考的结构相同,都由 start、若干 delta 和 end 组成,因此可以合并处理。
先实现开始事件:
case "text_start":
case "thinking_start": {
if (state.activeBlock) {
reject(event, state, `cannot start a new block while ${state.activeBlock.kind} is active`);
}
assertStartIndex(event, state, event.contentIndex);
state.activeBlock = {
kind: event.type === "text_start" ? "text" : "thinking",
contentIndex: event.contentIndex,
value: "",
};
return;
}
只有没有活动块,并且 contentIndex 等于 nextContentIndex 时,才能开始新块。这里不会立即增加索引,因为这个块还没有结束。
然后实现 delta:
case "text_delta":
case "thinking_delta": {
const expectedKind = event.type === "text_delta" ? "text" : "thinking";
const active = state.activeBlock;
if (!active || active.kind === "tool_call" || active.kind !== expectedKind) {
reject(event, state, `expected active ${expectedKind} block`);
}
if (event.contentIndex !== active.contentIndex) {
reject(event, state, `expected contentIndex ${active.contentIndex}, received ${event.contentIndex}`);
}
active.value += event.delta;
return;
}
例如当前活动块是 text_start(0),那么 thinking_delta(0) 和 text_delta(1) 都会被拒绝。只有类型和索引同时匹配,delta 才会追加到 active.value。
最后处理 end:
case "text_end":
case "thinking_end": {
const expectedKind = event.type === "text_end" ? "text" : "thinking";
const active = state.activeBlock;
if (!active || active.kind === "tool_call" || active.kind !== expectedKind) {
reject(event, state, `expected active ${expectedKind} block`);
}
if (event.contentIndex !== active.contentIndex) {
reject(event, state, `expected contentIndex ${active.contentIndex}, received ${event.contentIndex}`);
}
if (event.content !== active.value) {
reject(event, state, "end content does not match accumulated deltas");
}
state.completedContent.push(
expectedKind === "text"
? { type: "text", text: active.value }
: { type: "thinking", thinking: active.value },
);
state.activeBlock = undefined;
state.nextContentIndex += 1;
return;
}
这里的修改顺序很重要:先检查类型、索引和完整内容,全部通过后再写入 completedContent、清空活动块并增加索引。这样一个非法 end 不会污染状态。
实现工具调用内容块
工具调用同样有 start、delta 和 end,但它的 delta 是 JSON 字符串片段。例如:
第 1 段:{"pa
第 2 段:th":"README
第 3 段:.md"}
前两段都不是完整 JSON,因此不能对每个 delta 单独调用 JSON.parse()。正确做法是先拼接字符串,只在 tool_call_end 到达时解析一次。
先记录工具身份:
case "tool_call_start": {
if (state.activeBlock) {
reject(event, state, `cannot start a new block while ${state.activeBlock.kind} is active`);
}
assertStartIndex(event, state, event.contentIndex);
state.activeBlock = {
kind: "tool_call",
contentIndex: event.contentIndex,
id: event.id,
name: event.name,
argumentsJson: "",
sawArgumentsDelta: false,
};
return;
}
接收参数片段时只做类型、索引检查和字符串累积:
case "tool_call_delta": {
const active = state.activeBlock;
if (!active || active.kind !== "tool_call") {
reject(event, state, "expected active tool_call block");
}
if (event.contentIndex !== active.contentIndex) {
reject(event, state, `expected contentIndex ${active.contentIndex}, received ${event.contentIndex}`);
}
active.sawArgumentsDelta = true;
active.argumentsJson += event.argumentsDelta;
return;
}
到 end 时才解析并核对完整结果:
case "tool_call_end": {
const active = state.activeBlock;
if (!active || active.kind !== "tool_call") {
reject(event, state, "expected active tool_call block");
}
if (event.contentIndex !== active.contentIndex) {
reject(event, state, `expected contentIndex ${active.contentIndex}, received ${event.contentIndex}`);
}
let parsed: Record<string, JsonValue>;
if (active.sawArgumentsDelta) {
let candidate: unknown;
try {
candidate = JSON.parse(active.argumentsJson);
} catch {
reject(event, state, "tool arguments must be valid JSON");
}
if (!isJsonObject(candidate)) {
reject(event, state, "tool arguments must have a JSON object root");
}
parsed = candidate;
} else {
if (!isJsonObject(event.toolCall.arguments)) {
reject(event, state, "tool arguments must have a JSON object root");
}
parsed = event.toolCall.arguments;
}
if (event.toolCall.id !== active.id) {
reject(event, state, `tool id must remain "${active.id}"`);
}
if (event.toolCall.name !== active.name) {
reject(event, state, `tool name must remain "${active.name}"`);
}
if (
!isJsonObject(event.toolCall.arguments) ||
(active.sawArgumentsDelta && !jsonDeepEqual(parsed, event.toolCall.arguments))
) {
reject(event, state, "final tool arguments must match accumulated arguments");
}
state.completedContent.push({
type: "tool_call",
id: active.id,
name: active.name,
arguments: parsed,
});
state.activeBlock = undefined;
state.nextContentIndex += 1;
return;
}
这一段完成了四层检查:
- end 必须属于当前工具块。
- 参数必须是完整 JSON,并且根节点必须是对象。
- 工具的
id和name从 start 到 end 不能改变。 - end 中的
toolCall.arguments必须和所有 delta 拼出的对象一致。
如果从未收到 tool_call_delta,就直接使用 end 事件携带的参数对象。这让没有参数或由 Provider 一次性给出参数的工具调用也能通过。
实现 done 和 error
终止事件不仅表示“没有后续事件”,还携带最终的 AssistantMessage。状态机需要确认最终消息确实由前面的事件组成。
正常完成和异常中断的规则略有不同:
done表示正常完成,不能留下未结束的内容块。error可能发生在任意 delta 之后,可以保留已经生成的文本或思考片段。- 半截工具 JSON 不能成为可执行的
ToolCallContent,所以 error 时必须丢弃未完成工具块。
先在 jsonDeepEqual() 后、工厂函数前加入:
function getExpectedTerminalContent(state: SequenceState): AssistantContent[] {
const content = [...state.completedContent];
const active = state.activeBlock;
if (active?.kind === "text") {
content.push({ type: "text", text: active.value });
}
if (active?.kind === "thinking") {
content.push({ type: "thinking", thinking: active.value });
}
return content;
}
注意这里先复制 completedContent,不直接修改状态。这个函数只负责计算 error 消息应该携带什么内容。
然后在 switch 中加入两个终止分支:
case "done": {
if (state.activeBlock) {
reject(event, state, "done cannot terminate an active block");
}
if (event.reason !== event.message.stopReason) {
reject(event, state, "event reason must match message.stopReason");
}
if (!jsonDeepEqual(event.message.content, state.completedContent)) {
reject(event, state, "done message content must match completed stream content");
}
state.phase = "terminal";
return;
}
case "error": {
if (event.reason !== event.message.stopReason) {
reject(event, state, "event reason must match message.stopReason");
}
if (!jsonDeepEqual(event.message.content, getExpectedTerminalContent(state))) {
reject(event, state, "error message content must match safe partial stream content");
}
state.phase = "terminal";
return;
}
default:
reject(event, state, "event is not implemented at this step");
reason 和 message.stopReason 是同一个事实的两个表达位置,必须保持一致。内容也不能只相信终止事件自己声明,而要与状态机已经观察到的内容进行深比较。
接入 EventStream
现在校验器已经可以独立接收 StreamEvent,下一步是让真实的 EventStream.push() 自动调用它。
打开 packages/ai/src/utils/event-stream.ts,在文件顶部加入:
import type { AssistantMessage, StreamEvent } from "../types.ts";
import { createStreamEventValidator } from "./validation.ts";
不要修改上一篇已经实现的通用 EventStream<TEvent, TResult>。在类定义之后加入一个面向 AssistantMessage 的具体类型和工厂函数:
export type AssistantMessageEventStream = EventStream<StreamEvent, AssistantMessage>;
export function createAssistantMessageEventStream(): AssistantMessageEventStream {
const validator = createStreamEventValidator();
return new EventStream<StreamEvent, AssistantMessage>({
validate(event) {
validator.accept(event);
},
isTerminal(event) {
return event.type === "done" || event.type === "error";
},
getResult(event) {
if (event.type === "done" || event.type === "error") {
return event.message;
}
throw new Error("Expected terminal stream event");
},
});
}
这个工厂把三件事连接起来:
| EventStream 选项 | 具体行为 |
|---|---|
validate(event) | 把每个事件交给本次流独有的 validator |
isTerminal(event) | 把 done 和 error 识别为协议终止事件 |
getResult(event) | 从终止事件中取出最终 AssistantMessage |
这里必须在工厂函数内部调用 createStreamEventValidator()。如果把 validator 放在模块顶层,多个模型请求会共享状态:第一条流进入 terminal 后,第二条流的 start 也会被错误拒绝。
上一篇的 EventStream.push() 已经包含错误传播逻辑:
try {
this.options.validate(event);
// 判断终止事件并提取最终结果
} catch (cause) {
const error = normalizeError(cause);
this.fail(error);
throw error;
}
所以 validator 抛错后不需要再增加另一套错误通道。push() 会同步抛错,同时 fail() 会拒绝正在等待的消费者和 result()。
维护公共入口
最后打开 packages/ai/src/index.ts,加入公共导出:
export type { AssistantMessageEventStream, EventStreamOptions } from "./utils/event-stream.ts";
export { createAssistantMessageEventStream, EventStream } from "./utils/event-stream.ts";
export type { StreamEventValidator } from "./utils/validation.ts";
export { createStreamEventValidator, StreamSequenceError } from "./utils/validation.ts";
对外公开的是:
createAssistantMessageEventStream():Provider 创建消息事件流的主要入口。AssistantMessageEventStream:具体事件流类型。createStreamEventValidator():需要单独使用顺序校验器时的工厂。StreamSequenceError:调用者可以识别的协议顺序错误。StreamEventValidator:校验器的最小接口。
SequenceState、ActiveBlock、JsonValue 和深比较函数都只是实现细节,不应从包入口导出。这样以后调整状态机内部结构时,不会破坏使用 @di-code/ai 的代码。
格式化与构建
在 PowerShell 中进入项目根目录:
Set-Location D:\pi\di-code
使用项目已经安装的固定版本 Biome 整理这三个生产文件:
npx --no-install biome check --write packages\ai\src\index.ts packages\ai\src\utils\event-stream.ts packages\ai\src\utils\validation.ts
然后执行静态检查和 @di-code/ai 构建:
npm run check
npm run build --workspace @di-code/ai
npm run check 应该没有 warning 或 error,build 应该成功生成 packages/ai/dist。这两条命令只确认格式、类型和构建结果;事件顺序行为的测试不在本文展开。
总结
到这里,我们在通用 EventStream 前增加了一层有记忆的协议校验。它解决的不是“事件对象有没有这些字段”,而是“这个事件在当前时刻能不能出现”。
整个数据流现在是:
Provider 产生统一 StreamEvent
-> EventStream.push()
-> StreamEventValidator.accept()
-> 合法事件进入 queue 或直接唤醒 waiter
-> Agent 使用 for await...of 消费
-> done/error 携带的 AssistantMessage 由 result() 返回
状态机记住整条流的阶段、当前活动块、下一个内容索引和已完成内容,因此可以拒绝缺少 start、块类型错配、索引跳跃、工具参数不完整、最终消息不一致以及终止后继续推送等错误。
这一步仍然只校验统一事件协议和 JSON 数据边界。工具参数是否符合某个具体 TypeBox schema,将在下一篇文章中处理。
本章节git分支地址:eventstream-validation
如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金