本篇文章是《从零开发一个 Coding Agent》系列第七篇。在上一篇中,我们实现了一个可脚本化的 Faux Provider。它能够像真实大模型一样,持续产生 start、text_delta、done 等事件。
不过,Provider 只负责"模型说了什么",并不知道这些内容应该怎样变成 Agent 对外暴露的生命周期事件。例如:
- 用户消息什么时候进入本轮对话?
- 模型正在生成的半句话怎样交给终端实时显示?
- 模型生成完成后,最终的
AssistantMessage放到哪里? - 请求失败或被取消时,外层调用者怎样知道这一轮已经结束?
这些问题都由 Agent Loop(Agent 循环) 负责。
这一篇先实现最小版本:接收一条用户消息,只调用一次 Provider,把 Provider 的事件流转换成 Agent 的事件流,最后得到完整的消息数组。工具执行、连续多轮调用、Agent 类和 CLI 都留到后续文章。
Agent Loop 到底是什么
可以把一次模型请求想成去餐厅点餐:
- 用户消息是顾客写下的菜单。
- Provider 是厨房,负责一道道把菜送出来。
StreamEvent是厨房内部的出餐通知,例如"开始制作""先送来一部分""全部完成"。- Agent Loop 是传菜员,它读取厨房通知,再转换成前台能理解的服务流程。
AgentEvent是前台事件,例如"本轮开始""消息开始""消息更新""本轮结束"。
Provider 和 Agent Loop 处理的不是同一层事件:
text
Provider 事件流 Agent 事件流
start agent_start
text_start turn_start
text_delta("he") -> message_start
text_delta("llo") message_update
text_end message_end
done turn_end
agent_end
Provider 关心的是一条模型响应内部怎样生成;Agent 关心的是一次完整对话怎样开始、更新和结束。
两层 EventStream
在前面的文章中,我们已经实现了 EventStream<TEvent, TResult>。它同时提供两种读取方式:
ts
for await (const event of stream) {
// 实时读取每一个事件
}
const result = await stream.result();
// 流结束后读取最终结果
Agent Loop 会同时使用两个 EventStream:
内层流由 Provider 产生:
ts
EventStream<StreamEvent, AssistantMessage>
它的终止事件是 done 或 error,最终结果是一条 AssistantMessage。
外层流由 Agent Loop 产生:
ts
EventStream<AgentEvent, Message[]>
它的终止事件是 agent_end,最终结果是本轮结束后的完整消息数组。
这就是本篇最重要的设计:Agent Loop 不是重新实现一个事件通道,而是消费内层流,再生产一条语义更高的外层流。
Agent 包依赖 AI 包
在 monorepo 初始化文章中,我们已经约定依赖方向:
text
ai <- agent <- coding-agent
packages/ai 定义通用的消息、模型、Provider 和事件流;packages/agent 在这些基础契约之上实现控制流程。因此,在 packages/agent/package.json 中声明:
json
"dependencies": {
"@di-code/ai": "0.0.0"
}
然后在仓库根目录运行:
powershell
Set-Location D:\pi\di-code
npm install --ignore-scripts
npm workspaces 会把 @di-code/ai 链接到本地 packages/ai,于是 Agent 包可以像使用普通 npm 包一样导入公共类型:
ts
import type { Message, Model, Provider } from "@di-code/ai";
不要写跨包相对路径:
ts
// 不要这样做
import type { Message } from "../../ai/src/types.ts";
相对路径会绕过包的公共入口,让 agent 直接依赖 ai 的内部目录结构。以后 ai 移动文件时,所有上层包都会一起损坏。
先定义 Agent 使用的类型
打开:
text
packages/agent/src/types.ts
先导入 AI 包已经定义好的公共契约:
ts
import type {
AssistantMessage,
Message,
Model,
Provider,
StreamEvent,
UserMessage,
} from "@di-code/ai";
这里全部使用 import type,因为这些名字只在 TypeScript 类型检查阶段使用,运行时不需要加载它们。
对话上下文与运行配置
加入下面三个类型:
ts
export type AgentMessage = Message;
export interface AgentContext {
systemPrompt?: string;
messages: Message[];
}
export interface AgentLoopConfig {
readonly provider: Provider;
readonly model: Model;
readonly now?: () => number;
}
它们分别表示:
AgentMessage:Agent 当前允许保存的消息,暂时直接复用 AI 包的Message。AgentContext:发给模型的系统提示词和历史消息。AgentLoopConfig:本轮使用哪个 Provider、哪个模型,以及怎样获取时间。
now 是可选函数,不直接写死 Date.now(),是为了让测试可以注入固定时间。这样同一条测试每次运行都会得到相同结果。
为什么需要 AssistantMessagePreview
模型正在生成时,我们可能只拿到了:
text
he
过一会儿才变成:
text
hello
这时还不知道最终停止原因、token 使用量和结束时间,所以它还不是一条完整的 AssistantMessage。如果为了省事填入假的 usage 或 stopReason,上层代码就无法区分"正在生成"和"已经完成"。
因此定义一个预览类型:
ts
export interface AssistantMessagePreview {
readonly role: "assistant";
readonly provider: string;
readonly model: string;
readonly text: string;
}
预览消息只服务于实时显示,不进入正式对话历史。最终消息只能从 Provider 的 done 或 error 事件中取得。
定义 AgentEvent
Provider 的 start、done 和 error 是内层流的控制事件,不应该直接作为 message_update 暴露。因此先排除它们:
ts
export type MessageUpdateEvent = Exclude<
StreamEvent,
{ type: "start" | "done" | "error" }
>;
然后定义完整的 Agent 生命周期:
ts
export type AgentEvent =
| { type: "agent_start" }
| { type: "turn_start" }
| { type: "message_start"; message: UserMessage | AssistantMessagePreview }
| {
type: "message_update";
event: MessageUpdateEvent;
message: AssistantMessagePreview;
}
| { type: "message_end"; message: UserMessage | AssistantMessage }
| { type: "turn_end"; message: AssistantMessage }
| { type: "agent_end"; messages: Message[] };
这是一种 可辨识联合类型(discriminated union) 。每个成员都有不同的 type 字段,因此消费者可以安全地缩小类型:
ts
function handleEvent(event: AgentEvent): void {
if (event.type === "message_update") {
console.log(event.message.text);
}
}
TypeScript 看到 event.type === "message_update" 后,就知道当前事件一定含有 message 和 event 字段。
一次正常请求会发生什么
假设用户输入:
text
say hello
Faux Provider 设置 chunkSize: 2,会把 hello 拆成:
text
"he" -> "ll" -> "o"
Provider 产生的完整事件顺序是:
text
start
text_start
text_delta("he")
text_delta("ll")
text_delta("o")
text_end("hello")
done
Agent Loop 对外产生:
text
agent_start
turn_start
message_start(user)
message_end(user)
message_start(assistant preview: "")
message_update(text_start, preview: "")
message_update(text_delta, preview: "he")
message_update(text_delta, preview: "hell")
message_update(text_delta, preview: "hello")
message_update(text_end, preview: "hello")
message_end(final assistant)
turn_end(final assistant)
agent_end([user, assistant])
注意,message_update 不只对应 text_delta。text_start 和 text_end 也是消息生成过程的一部分,也要向外投影。因此 hello 虽然只有 3 个文本分片,却会产生 5 个 message_update。
创建外层 Agent EventStream
创建文件:
text
packages/agent/src/agent-loop.ts
先加入导入和函数签名:
ts
import {
type AssistantMessage,
EventStream,
type Message,
type UserMessage,
} from "@di-code/ai";
import type {
AgentContext,
AgentEvent,
AgentLoopConfig,
AssistantMessagePreview,
} from "./types.ts";
export function agentLoop(
prompt: UserMessage,
context: AgentContext,
config: AgentLoopConfig,
signal?: AbortSignal,
): EventStream<AgentEvent, Message[]> {
const stream = createAgentEventStream();
queueMicrotask(() => {
void runAgentLoop(prompt, context, config, signal, stream);
});
return stream;
}
这里故意让 agentLoop() 同步返回一个 EventStream,而不是返回 Promise<EventStream>。调用者拿到通道后,可以马上开始监听:
ts
const stream = agentLoop(prompt, context, config);
for await (const event of stream) {
console.log(event.type);
}
真正的异步工作交给 runAgentLoop()。queueMicrotask() 会让它在当前同步代码结束后启动,明确分开"创建并返回通道"和"开始生产事件"两个阶段。
然后创建外层事件流:
ts
function createAgentEventStream(): EventStream<AgentEvent, Message[]> {
return new EventStream<AgentEvent, Message[]>({
validate() {},
isTerminal(event) {
return event.type === "agent_end";
},
getResult(event) {
if (event.type !== "agent_end") {
throw new Error("Expected agent_end event");
}
return event.messages;
},
});
}
这里的终止事件必须是 agent_end,不能使用 Provider 的 done。原因是 Provider 完成后,Agent Loop 还要做三件事:
- 把最终 AssistantMessage 加入消息数组。
- 发出
message_end和turn_end。 - 发出携带完整结果的
agent_end。
如果看到 Provider 的 done 就结束外层流,调用者会丢失这些 Agent 生命周期事件。
创建实时预览
加入一个小函数,根据当前累计文本创建预览:
ts
function createPreview(
config: AgentLoopConfig,
text: string,
): AssistantMessagePreview {
return {
role: "assistant",
provider: config.model.provider,
model: config.model.id,
text,
};
}
每次收到新的 text_delta 后,我们会更新累计字符串,再创建一个新的预览对象:
text
"" -> "he" -> "hell" -> "hello"
这里没有修改旧对象,而是生成新对象。上层界面如果保存了旧事件,就不会被后续更新偷偷改变。
把 Provider 事件投影为 Agent 事件
接下来实现核心函数:
ts
async function runAgentLoop(
prompt: UserMessage,
context: AgentContext,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
stream: EventStream<AgentEvent, Message[]>,
): Promise<void> {
const messages: Message[] = [...context.messages, prompt];
const emit = (event: AgentEvent): void => stream.push(event);
emit({ type: "agent_start" });
emit({ type: "turn_start" });
emit({ type: "message_start", message: prompt });
emit({ type: "message_end", message: prompt });
let text = "";
emit({
type: "message_start",
message: createPreview(config, text),
});
let assistant: AssistantMessage;
try {
const response = config.provider.stream(
config.model,
{ systemPrompt: context.systemPrompt, messages },
{ signal },
);
let terminalMessage: AssistantMessage | undefined;
for await (const event of response) {
if (event.type === "done" || event.type === "error") {
terminalMessage = event.message;
continue;
}
if (event.type === "start") {
continue;
}
if (event.type === "text_delta") {
text += event.delta;
}
emit({
type: "message_update",
event,
message: createPreview(config, text),
});
}
assistant = terminalMessage ?? (await response.result());
} catch (cause) {
assistant = createFailureMessage(config, signal, cause);
}
messages.push(assistant);
emit({ type: "message_end", message: assistant });
emit({ type: "turn_end", message: assistant });
emit({ type: "agent_end", messages });
}
这一段代码可以拆成四个阶段理解。
第一阶段:准备本轮消息
ts
const messages: Message[] = [...context.messages, prompt];
这里创建一个新数组,而不是直接执行:
ts
context.messages.push(prompt);
Agent Loop 不应该偷偷修改调用方传入的上下文。只有本轮完整结束后,上层 Agent 才能决定是否提交新历史。
第二阶段:宣布用户消息已经进入本轮
ts
emit({ type: "agent_start" });
emit({ type: "turn_start" });
emit({ type: "message_start", message: prompt });
emit({ type: "message_end", message: prompt });
用户消息在调用 agentLoop() 之前已经是完整对象,所以 message_start 后面可以立刻跟 message_end,不需要 message_update。
第三阶段:消费 Provider 流
ts
for await (const event of response) {
// 把 StreamEvent 转换成 AgentEvent
}
这里正是第二篇文章所说的生产者-消费者模型:
- Faux Provider 是生产者,调用
push()写入StreamEvent。 - Agent Loop 是消费者,通过
for await...of逐条读取。 - Agent Loop 同时又是外层流的生产者,把
AgentEvent推给 CLI、TUI 或其他调用者。
所以 Agent Loop 同时扮演"内层消费者"和"外层生产者"。
第四阶段:收敛为正式消息
Provider 的 done 和 error 都带有最终 AssistantMessage:
ts
if (event.type === "done" || event.type === "error") {
terminalMessage = event.message;
}
这个最终对象已经包含:
- 完整的
content; provider和model;- token
usage; timestamp;stopReason;- 失败时的
errorMessage。
因此,正式历史必须使用它,不能把只含累计文本的 preview 塞进消息数组。
处理 Provider 直接抛错
规范内的模型失败会产生 error 事件。但传输层或第三方 Provider 也可能在创建流或迭代流时直接抛出异常,例如:
ts
stream() {
throw new Error("transport broke");
}
Agent Loop 不应该因此永远停在"正在生成"状态。它需要把异常转换成一条失败的 AssistantMessage,然后照常发出 message_end、turn_end 和 agent_end。
先准备零用量对象:
ts
function zeroUsage(): AssistantMessage["usage"] {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
}
再创建失败消息:
ts
function createFailureMessage(
config: AgentLoopConfig,
signal: AbortSignal | undefined,
cause: unknown,
): AssistantMessage {
const aborted = signal?.aborted === true;
return {
role: "assistant",
content: [],
provider: config.model.provider,
model: config.model.id,
usage: zeroUsage(),
timestamp: (config.now ?? Date.now)(),
stopReason: aborted ? "aborted" : "error",
errorMessage: cause instanceof Error ? cause.message : String(cause),
};
}
cause 使用 unknown,因为 JavaScript 允许抛出任意值:
ts
throw new Error("network failed");
throw "network failed";
只有先用 instanceof Error 缩小类型,才能安全读取 .message。
如果 AbortSignal 已经取消,就使用 stopReason: "aborted";否则使用 stopReason: "error"。两种情况都会正常走到 agent_end,这叫做 settled(已收敛):无论成功、失败还是取消,本轮都进入明确的结束状态。
取消为什么要继续发 agent_end
调用方通过 AbortController 取消请求:
ts
const controller = new AbortController();
const stream = agentLoop(
prompt,
context,
config,
controller.signal,
);
controller.abort("user cancelled");
取消不是让整个控制流突然消失,而是要求 Provider 尽快停止继续生成。Provider 会把已经安全接收的部分内容放入失败消息,并以 stopReason: "aborted" 结束。
Agent Loop 仍然必须发出:
text
message_end
turn_end
agent_end
否则上层界面只看到 message_start,却永远等不到结束事件,加载动画和 isStreaming 状态就无法复位。
导出公共入口
打开:
text
packages/agent/src/index.ts
通过包的根入口导出类型和函数:
ts
export * from "./agent-loop.ts";
export * from "./types.ts";
上层包以后只需要写:
ts
import { agentLoop } from "@di-code/agent";
而不需要依赖 @di-code/agent/src/agent-loop.ts 这样的内部路径。
使用 Faux Provider 测试完整生命周期
测试文件位于:
text
packages/agent/test/agent-loop.test.ts
先准备用户消息、上下文和事件收集函数:
ts
import { createFauxProvider } from "@di-code/ai";
import { describe, expect, it } from "vitest";
import { agentLoop } from "../src/index.ts";
import type {
AgentContext,
AgentEvent,
AgentMessage,
} from "../src/index.ts";
function userMessage(
text: string,
timestamp = 10,
): Extract<AgentMessage, { role: "user" }> {
return {
role: "user",
content: [{ type: "text", text }],
timestamp,
};
}
function context(messages: AgentMessage[] = []): AgentContext {
return {
systemPrompt: "You are concise.",
messages,
};
}
async function collect(
stream: AsyncIterable<AgentEvent>,
): Promise<AgentEvent[]> {
const events: AgentEvent[] = [];
for await (const event of stream) {
events.push(event);
}
return events;
}
测试正常文本响应
ts
it("calls provider once and emits stable text lifecycle", async () => {
const faux = createFauxProvider({
responses: [
{
type: "success",
content: [{ type: "text", text: "hello" }],
},
],
chunkSize: 2,
now: () => 20,
});
const stream = agentLoop(userMessage("say hello"), context(), {
provider: faux.provider,
model: faux.model,
});
const events = await collect(stream);
const messages = await stream.result();
expect(events.map((event) => event.type)).toEqual([
"agent_start",
"turn_start",
"message_start",
"message_end",
"message_start",
"message_update",
"message_update",
"message_update",
"message_update",
"message_update",
"message_end",
"turn_end",
"agent_end",
]);
expect(messages.map((message) => message.role)).toEqual([
"user",
"assistant",
]);
expect(messages[1]).toMatchObject({
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
});
expect(faux.pendingResponses()).toBe(0);
});
这个测试同时证明了四件事:
- Agent 生命周期顺序固定。
- Provider 的文本分片最终被合成为
hello。 - 外层
result()返回用户消息和最终助手消息。 - Faux 响应队列已经消费完,说明本轮只调用了一次 Provider。
测试模型错误
ts
it("settles with a failed assistant message for a model error", async () => {
const faux = createFauxProvider({
responses: [
{ type: "failure", errorMessage: "model failed" },
],
now: () => 30,
});
const stream = agentLoop(userMessage("fail"), context(), {
provider: faux.provider,
model: faux.model,
});
const events = await collect(stream);
const messages = await stream.result();
expect(events.at(-1)?.type).toBe("agent_end");
expect(messages.at(-1)).toMatchObject({
role: "assistant",
stopReason: "error",
errorMessage: "model failed",
});
});
模型返回错误不等于外层 EventStream.result() 必须 reject。错误已经被规范化为一条失败的 AssistantMessage,所以本轮仍然可以返回完整、可保存、可显示的结果。
测试生成中取消
ts
it("settles after cancellation at the first text delta", async () => {
const controller = new AbortController();
const faux = createFauxProvider({
responses: [
{
type: "success",
content: [{ type: "text", text: "abcdef" }],
},
],
chunkSize: 2,
now: () => 40,
});
const stream = agentLoop(
userMessage("cancel"),
context(),
{ provider: faux.provider, model: faux.model },
controller.signal,
);
const events: AgentEvent[] = [];
for await (const event of stream) {
events.push(event);
if (
event.type === "message_update" &&
event.event.type === "text_delta"
) {
controller.abort("test cancellation");
}
}
const messages = await stream.result();
expect(events.at(-1)?.type).toBe("agent_end");
expect(messages.at(-1)).toMatchObject({
stopReason: "aborted",
content: [{ type: "text", text: "ab" }],
});
});
chunkSize 是 2,所以第一次 text_delta 是 ab。测试收到它后立刻取消,最终消息只能保留已经产生的 ab,不能凭空出现后面的 cdef。
测试 Provider 同步抛错
ts
it("normalizes a provider throw into a settled error turn", async () => {
const model = {
id: "broken-model",
name: "Broken",
provider: "broken",
api: "broken",
input: ["text" as const],
reasoning: false,
contextWindow: 100,
maxOutputTokens: 10,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
};
const provider = {
id: "broken",
name: "Broken",
models: [model],
stream() {
throw new Error("transport broke");
},
};
const stream = agentLoop(
userMessage("throw"),
context(),
{ provider, model },
);
const events = await collect(stream);
const messages = await stream.result();
expect(events.at(-1)?.type).toBe("agent_end");
expect(messages.at(-1)).toMatchObject({
stopReason: "error",
errorMessage: "transport broke",
});
});
这条测试覆盖的是协议外异常:Provider 连事件流都没有成功创建。即使如此,Agent Loop 也必须补齐自己的结束事件。
运行验证
在 PowerShell 中进入项目根目录:
powershell
Set-Location D:\pi\di-code
先运行 Agent Loop 的定向测试:
powershell
npm test --workspace packages/agent -- --run agent-loop
当前项目应收集 1 个测试文件,共 5 个测试,并全部通过:
text
Test Files 1 passed (1)
Tests 5 passed (5)
然后运行类型、格式和构建检查:
powershell
npm run check
npm run build --workspace @di-code/agent
最后确认底层 AI 包没有回归:
powershell
npm test --workspace @di-code/ai
当前应看到 5 个测试文件、55 个测试全部通过。
总结
这一篇把前面完成的基础零件真正串了起来:
text
UserMessage
|
v
Agent Loop
|
+-> Provider.stream()
| |
| v
| StreamEvent
| |
+-------+
|
v
AgentEvent + Message[]
Agent Loop 的核心职责可以概括为四句话:
- 为一次请求建立稳定的 Agent 生命周期。
- 消费 Provider 的细粒度事件,并投影为上层可理解的 AgentEvent。
- 用 preview 表示生成中的内容,用 AssistantMessage 表示最终结果。
- 保证成功、错误和取消最终都收敛到
agent_end。
到这里,我们已经拥有了一个可以由 Faux Provider 驱动的纯文本 Agent Loop。它还不会执行工具,也没有长期状态,但最重要的控制流骨架已经建立起来。后续功能会在这条稳定骨架上逐层增加,而不是重新发明另一套消息和事件机制。
本章节git分支地址:qddidi/di-code at 0810/faux-provider
如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金