从零开发一个 Coding Agent(四):使用状态机校验大模型事件流

本篇文章是《从零开发一个 Coding Agent》系列第四篇。在上一篇中,我们实现了一个通用的 EventStream 通道:Provider 负责调用 push() 写入事件,Agent 使用 for await...of 读取事件。

但是,EventStream 只负责运输数据,并不知道事件出现的顺序是否正确。例如下面这个对象完全符合 StreamEvent 的 TypeScript 类型:

ts 复制代码
{ type: "text_delta", contentIndex: 0, delta: "你好" }

如果它前面没有出现 starttext_start,它在协议上仍然是非法事件。TypeScript 只能检查单个对象的字段,不能记住这条流之前发生过什么。因此,我们还需要一个有记忆的校验器,也就是状态机(State Machine)。

完成后,事件进入 EventStream 之前会先经过顺序校验:

text 复制代码
Provider -> EventStream.push(event) -> StreamEventValidator.accept(event) -> Agent

合法事件会继续进入队列;非法事件会抛出 StreamSequenceError,并让事件生产者、异步迭代器和 result() 得到同一个错误。

事件流的状态

先看一条合法的文本响应:

text 复制代码
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:已经收到 doneerror,不能再接收事件。

除了整条流的阶段,我们还要记录当前正在拼接的内容块。一个 AssistantMessage 可以包含文本、思考过程和工具调用:

text 复制代码
content[0] -> text
content[1] -> thinking
content[2] -> tool_call

因此,状态机还要保证:

  1. 同一时刻最多有一个未结束的内容块。
  2. contentIndex 从 0 开始连续递增。
  3. delta 必须属于当前活动块。
  4. end 携带的完整内容必须等于所有 delta 的累积结果。
  5. 一个块完全结束后,才能开始下一个索引。

定义状态和顺序错误

packages/ai/src/utils 目录中新建 validation.ts,先导入公共事件与消息内容类型:

ts 复制代码
import type { AssistantContent, StreamEvent } from "../types.ts";

type StreamPhase = "idle" | "streaming" | "terminal";

然后定义活动内容块:

ts 复制代码
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"是两种不同的协议状态。

接着定义整条流的内部状态:

ts 复制代码
interface SequenceState {
	phase: StreamPhase;
	nextContentIndex: number;
	activeBlock?: ActiveBlock;
	completedContent: AssistantContent[];
}

这里四个字段分别表示:

字段 作用
phase 当前处于开始前、流式传输中,还是已经终止
nextContentIndex 下一个内容块必须使用的索引
activeBlock 当前正在累积的内容块
completedContent 已经完整结束的消息内容,用来核对最终消息

普通的 Error 只能告诉我们"出错了",排查 Provider 时还需要知道哪个事件在哪个阶段出错。因此定义一个可识别的错误类型:

ts 复制代码
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() 可以在把事件交给消费者之前完成校验。

再加入两个辅助函数,统一错误格式并检查新内容块的索引:

ts 复制代码
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、函数、DateMapInfinity 都不属于合法 JSON 数据。

先定义 JSON 可以表达的递归类型:

ts 复制代码
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };

再加入运行时检查:

ts 复制代码
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 无法准确表示的 NaNInfinity-Infinity

工具参数还必须以对象为根,所以 isJsonObject() 会排除 null 和数组。这里检查的是"它是不是合法 JSON 对象",并不检查 { path: 123 } 是否符合某个具体工具的参数 schema;具体工具参数校验属于后续 Task 03c。

工具参数在 delta 中是字符串,在 end 事件中已经是对象。JSON.parse() 每次都会创建新对象,因此不能使用 === 比较引用,需要按结构和值进行深比较:

ts 复制代码
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()。它不是一个全局单例,而是每调用一次都创建一份独立状态:

ts 复制代码
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 组成,因此可以合并处理。

先实现开始事件:

ts 复制代码
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:

ts 复制代码
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:

ts 复制代码
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 字符串片段。例如:

text 复制代码
第 1 段:{"pa
第 2 段:th":"README
第 3 段:.md"}

前两段都不是完整 JSON,因此不能对每个 delta 单独调用 JSON.parse()。正确做法是先拼接字符串,只在 tool_call_end 到达时解析一次。

先记录工具身份:

ts 复制代码
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;
}

接收参数片段时只做类型、索引检查和字符串累积:

ts 复制代码
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 时才解析并核对完整结果:

ts 复制代码
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;
}

这一段完成了四层检查:

  1. end 必须属于当前工具块。
  2. 参数必须是完整 JSON,并且根节点必须是对象。
  3. 工具的 idname 从 start 到 end 不能改变。
  4. end 中的 toolCall.arguments 必须和所有 delta 拼出的对象一致。

如果从未收到 tool_call_delta,就直接使用 end 事件携带的参数对象。这让没有参数或由 Provider 一次性给出参数的工具调用也能通过。

实现 done 和 error

终止事件不仅表示"没有后续事件",还携带最终的 AssistantMessage。状态机需要确认最终消息确实由前面的事件组成。

正常完成和异常中断的规则略有不同:

  • done 表示正常完成,不能留下未结束的内容块。
  • error 可能发生在任意 delta 之后,可以保留已经生成的文本或思考片段。
  • 半截工具 JSON 不能成为可执行的 ToolCallContent,所以 error 时必须丢弃未完成工具块。

先在 jsonDeepEqual() 后、工厂函数前加入:

ts 复制代码
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 中加入两个终止分支:

ts 复制代码
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");

reasonmessage.stopReason 是同一个事实的两个表达位置,必须保持一致。内容也不能只相信终止事件自己声明,而要与状态机已经观察到的内容进行深比较。

接入 EventStream

现在校验器已经可以独立接收 StreamEvent,下一步是让真实的 EventStream.push() 自动调用它。

打开 packages/ai/src/utils/event-stream.ts,在文件顶部加入:

ts 复制代码
import type { AssistantMessage, StreamEvent } from "../types.ts";
import { createStreamEventValidator } from "./validation.ts";

不要修改上一篇已经实现的通用 EventStream<TEvent, TResult>。在类定义之后加入一个面向 AssistantMessage 的具体类型和工厂函数:

ts 复制代码
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) doneerror 识别为协议终止事件
getResult(event) 从终止事件中取出最终 AssistantMessage

这里必须在工厂函数内部调用 createStreamEventValidator()。如果把 validator 放在模块顶层,多个模型请求会共享状态:第一条流进入 terminal 后,第二条流的 start 也会被错误拒绝。

上一篇的 EventStream.push() 已经包含错误传播逻辑:

ts 复制代码
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,加入公共导出:

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:校验器的最小接口。

SequenceStateActiveBlockJsonValue 和深比较函数都只是实现细节,不应从包入口导出。这样以后调整状态机内部结构时,不会破坏使用 @di-code/ai 的代码。

格式化与构建

在 PowerShell 中进入项目根目录:

powershell 复制代码
Set-Location D:\pi\di-code

使用项目已经安装的固定版本 Biome 整理这三个生产文件:

powershell 复制代码
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 构建:

powershell 复制代码
npm run check
npm run build --workspace @di-code/ai

npm run check 应该没有 warning 或 error,build 应该成功生成 packages/ai/dist。这两条命令只确认格式、类型和构建结果;事件顺序行为的测试不在本文展开。

总结

到这里,我们在通用 EventStream 前增加了一层有记忆的协议校验。它解决的不是"事件对象有没有这些字段",而是"这个事件在当前时刻能不能出现"。

整个数据流现在是:

text 复制代码
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 - 东方小月的专栏 - 掘金

相关推荐
大龄码农有梦想1 小时前
使用大模型服务如何保证数据安全?企业 AI 安全、私有化部署与治理实践
人工智能·私有化部署·数据安全·信创·ai agent·智能体·智能体开发平台
冬奇Lab1 小时前
每日一个开源项目(第170篇):CodeWiki - ACL 2026 论文级代码库自动文档生成,递归多 Agent 架构
人工智能·开源·资讯
Csvn1 小时前
🧩 ESM vs CJS 混用的 7 个「天坑」——从 TypeScript 编译到 Node 与浏览器
前端
lxw18449125142 小时前
Claude-Code企业级培训教程
人工智能
都叫我大帅哥2 小时前
“让AI修就行”?我劝你收回这句话——来自一位Java博主的硬核反常识
后端
冬奇Lab2 小时前
代码库知识库系列(01):技术全景——为什么代码理解比文档检索难十倍
人工智能
Csvn2 小时前
🎯 Web 性能 API 集合:Performance Observer 的 5 个冷门妙用
前端
MartinYeung52 小时前
[论文学习]迈向自主医疗人工智能智能体:MIRA系统深度分析
人工智能·学习