本篇文章是《从零开发一个 Coding Agent》系列第六篇。在前几篇中,我们已经完成了事件流通道、事件顺序校验和工具参数校验。不过目前这些能力还只是底层零件,我们还没有一个真正的 Provider 可以持续产生合法事件。
直接接入真实大模型并不适合当前阶段。真实接口需要网络和密钥,返回内容也不固定,还可能产生费用。只要模型这一次换了一个词或分块位置,Agent 的运行结果就会变化,很难判断问题究竟出在 Agent,还是出在模型本身。
这一篇将实现一个 faux provider。faux 可以理解为"仿造的"或"模拟的":调用方提前写好模型应该返回的内容,faux provider 再把这些内容转换成与真实 Provider 相同的流事件。
例如提前写入一条文本响应:
ts
const faux = createFauxProvider({
responses: [
{
type: "success",
content: [{ type: "text", text: "hello" }],
},
],
chunkSize: 2,
now: () => 1234,
});
调用 stream() 后会得到稳定的事件顺序:
text
start
text_start(0)
text_delta(0, "he")
text_delta(0, "ll")
text_delta(0, "o")
text_end(0, "hello")
done(stop, AssistantMessage)
它不访问网络,也不会调用真实模型,但对于上层 Agent 来说,它就是一个普通的 Provider。后续实现 Agent Loop 时,我们可以先用它把文本、思考、工具调用、失败和取消流程稳定地串起来。
Faux Provider 在项目中的位置
本篇代码仍然位于最底层的 packages/ai 包:
text
预先编写的 FauxResponse[]
|
v
createFauxProvider()
|
v
Provider.stream()
|
v
AssistantMessageEventStream
|
+------+------+
| |
v v
for await...of result()
逐条读取事件 获取最终消息
具体依赖关系是:
这里有一条很重要的边界:faux provider 只负责模拟模型输出,不负责执行工具,也不负责修改对话历史。
Context可以传进来,但本篇不会读取或改写它。- 工具调用只生成
tool_call内容和对应事件,不会真的运行工具。 - 文件系统、命令执行、Session 和终端界面都不属于
packages/ai。
这样以后把 faux provider 换成真实 Provider 时,上层 Agent 不需要改变自己的控制流程。
先理解 Provider 的同步与异步边界
打开:
text
packages/ai/src/types.ts
当前 Provider 契约如下:
ts
export interface Provider {
readonly id: string;
readonly name: string;
readonly models: readonly Model[];
stream(model: Model, context: Context, options?: StreamOptions): StreamResult;
}
注意 stream() 返回的是 StreamResult,不是 Promise<StreamResult>。也就是说,下面的调用不需要 await:
ts
const stream = provider.stream(model, context);
但流里面的数据仍然是异步到达的:
ts
for await (const event of stream) {
console.log(event);
}
这看起来像是矛盾,其实表示两个不同阶段:
stream()先同步创建并返回一个空的事件通道。- Provider 在后台异步向通道中
push()事件。 - 调用方通过
for await...of等待并读取这些事件。
本篇会使用 queueMicrotask() 启动后台 producer(生产者)。它的作用不是模拟网络延迟,而是把"返回 stream"和"开始 push 事件"分到两个执行时机。
创建文件并定义脚本类型
在下面的位置创建文件:
text
packages/ai/src/providers/faux.ts
先引入本篇需要的类型和事件流工厂:
ts
import type {
AssistantContent,
FailedAssistantMessage,
FailedStopReason,
Model,
Provider,
SuccessfulAssistantMessage,
SuccessfulStopReason,
Usage,
} from "../types.ts";
import { createAssistantMessageEventStream } from "../utils/event-stream.ts";
然后定义 faux provider 能够读取的响应脚本:
ts
export type FauxResponse =
| {
type: "success";
content: AssistantContent[];
stopReason?: SuccessfulStopReason;
}
| {
type: "failure";
errorMessage: string;
};
这是一个可辨识联合类型(discriminated union)。type 字段决定当前对象是哪一种响应:
success表示模型正常返回,content中可以包含文本、思考或工具调用。failure表示模型返回失败,只需要提供稳定的错误文本。
这里没有让脚本直接接收任意 StreamEvent[]。如果调用方可以随便拼事件,就很容易写出缺少 start、delta 顺序错误或没有终结事件的非法流。FauxResponse 描述的是"模型最终想返回什么",至于怎样把它翻译成合法流,应该由 Provider 自己负责。
接着定义工厂选项和返回结果:
ts
export interface FauxProviderOptions {
responses: readonly FauxResponse[];
chunkSize?: number;
now?: () => number;
}
export interface FauxProviderHandle {
readonly provider: Provider;
readonly model: Model;
pendingResponses(): number;
}
三个选项的作用分别是:
| 字段 | 作用 |
|---|---|
responses |
提前写好的响应队列,每次调用取出一条 |
chunkSize |
每个增量最多包含多少个 JavaScript 字符单元,默认值是 4 |
now |
生成消息时间戳的函数,默认使用 Date.now |
工厂没有只返回一个 Provider,而是返回 FauxProviderHandle。调用方除了要调用 provider,还经常需要拿到与它匹配的固定模型,并查看队列里还剩多少条响应。把三者放在一个 handle 中,不需要额外的全局模型注册表。
规范化响应并固定模型
用户写入的成功响应可以省略 stopReason,但是 producer 真正运行时不应该反复判断它是否存在。因此先定义一个内部类型:
ts
type NormalizedResponse =
| {
type: "success";
content: AssistantContent[];
stopReason: SuccessfulStopReason;
}
| {
type: "failure";
errorMessage: string;
};
公开的 FauxResponse 方便调用方编写,内部的 NormalizedResponse 则保证成功响应一定有停止原因。
接下来定义固定模型:
ts
const FAUX_MODEL: Model = {
id: "faux-model",
name: "Faux Model",
provider: "faux",
api: "faux",
input: ["text", "image"],
reasoning: true,
contextWindow: 128_000,
maxOutputTokens: 16_384,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
这些值不是在冒充某个真实模型,而是在满足项目已经定义好的 Model 契约。模型 id、上下文长度和价格始终固定,运行结果就不会受外部配置影响。faux 不产生费用,因此所有 cost 都是 0。
然后实现响应规范化:
ts
function normalizeResponse(response: FauxResponse): NormalizedResponse {
if (response.type === "failure") {
return { ...response };
}
const hasToolCall = response.content.some((block) => block.type === "tool_call");
if (hasToolCall && response.stopReason !== undefined && response.stopReason !== "tool_use") {
throw new Error("tool_call content requires stopReason tool_use");
}
if (!hasToolCall && response.stopReason === "tool_use") {
throw new Error("stopReason tool_use requires tool_call content");
}
return {
...response,
content: [...response.content],
stopReason: response.stopReason ?? (hasToolCall ? "tool_use" : "stop"),
};
}
停止原因必须与内容一致:
- 包含工具调用时,停止原因只能是
tool_use。 - 不包含工具调用时,不能写成
tool_use。 - 调用方没有填写时,有工具调用自动使用
tool_use,否则使用stop。
为什么要在创建工厂时就拒绝矛盾数据?因为上层 Agent 会根据 stopReason 决定下一步。如果消息中明明有工具调用,停止原因却是 stop,Agent 可能直接结束循环,永远不执行工具。
content: [...response.content] 会复制外层数组,避免后续对原数组的增删直接改变已经进入队列的脚本。
分块与消息构造
真实模型通常不会一次返回完整文本,而是不断返回增量。faux provider 也需要把完整字符串切成小块:
ts
function splitIntoChunks(value: string, chunkSize: number): string[] {
const chunks: string[] = [];
for (let offset = 0; offset < value.length; offset += chunkSize) {
chunks.push(value.slice(offset, offset + chunkSize));
}
return chunks;
}
例如:
ts
splitIntoChunks("hello", 2);
// ["he", "ll", "o"]
这里的 chunkSize 不是 token 数。它只是 JavaScript 字符串的切片大小,用于稳定地产生多条 delta。空字符串会得到空数组,因此仍然可以发送 text_start 和 text_end,但不会制造没有内容的空 delta。
每条最终消息都要包含 usage。faux 不计算真实 token,所以创建一份全零数据:
ts
function createZeroUsage(): Usage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
这个函数每次都返回新对象,而不是共享一份全局对象。这样外部即使意外修改某条消息的 usage,也不会污染下一条消息。
成功消息和失败消息的公共字段相同,但停止原因和错误字段不同:
ts
function createSuccessMessage(
model: Model,
content: AssistantContent[],
stopReason: SuccessfulStopReason,
timestamp: number,
): SuccessfulAssistantMessage {
return {
role: "assistant",
content: [...content],
provider: model.provider,
model: model.id,
usage: createZeroUsage(),
timestamp,
stopReason,
};
}
function createFailureMessage(
model: Model,
content: AssistantContent[],
stopReason: FailedStopReason,
errorMessage: string,
timestamp: number,
): FailedAssistantMessage {
return {
role: "assistant",
content: [...content],
provider: model.provider,
model: model.id,
usage: createZeroUsage(),
timestamp,
stopReason,
errorMessage,
};
}
SuccessfulAssistantMessage 不应该有 errorMessage,而 FailedAssistantMessage 必须有它。使用两个构造函数可以让这种区别在类型层面保持清楚。
记录 producer 的进度
取消可能发生在一条响应的中间。为了构造准确的失败消息,我们需要知道哪些内容块已经完成,以及当前文本已经输出了多少。
加入进度类型:
ts
interface ProducerProgress {
completedContent: AssistantContent[];
activePartial?: { type: "text"; text: string } | { type: "thinking"; thinking: string };
}
这里故意只允许文本和 thinking 出现在 activePartial 中:
- 文本输出了
"hel"后取消,这段字符串仍然是安全、可展示的内容。 - thinking 输出了一部分后取消,也可以保留已经形成的字符串。
- 工具参数如果只输出了一半 JSON,它还不是完整对象,不能放进最终消息,更不能交给工具执行。
根据进度获取失败消息中的内容:
ts
function getPartialContent(progress: ProducerProgress): AssistantContent[] {
return progress.activePartial
? [...progress.completedContent, progress.activePartial]
: [...progress.completedContent];
}
再加入取消检查和统一失败出口:
ts
function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true;
}
function pushFailure(
stream: ReturnType<typeof createAssistantMessageEventStream>,
model: Model,
now: () => number,
progress: ProducerProgress,
stopReason: FailedStopReason,
errorMessage: string,
): void {
const message = createFailureMessage(model, getPartialContent(progress), stopReason, errorMessage, now());
stream.push({ type: "error", reason: stopReason, message });
}
event.reason 和 message.stopReason 必须使用同一个值。把两者集中在 pushFailure() 中构造,可以避免一处写成 error、另一处写成 aborted。
为什么每个 delta 后要主动让出执行权
AbortSignal 是协作式取消。调用 controller.abort() 不会强行结束正在运行的 JavaScript 函数,producer 必须主动检查 signal.aborted。
如果 producer 在一个同步循环里把所有 delta 全部 push 完,再检查取消,那么消费者即使收到第一段后立刻调用 abort(),剩余内容也早已进入队列。为了让消费者有机会在两个 delta 之间运行,加入:
ts
async function yieldToConsumer(): Promise<void> {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
setImmediate() 会让 producer 到下一轮事件循环再继续。当前这一轮里,消费者读取事件后产生的 Promise continuation 可以先执行,于是它能够及时调用 abort()。
这里不能简单替换成一次 await Promise.resolve()。在第一个 delta 进入队列前,producer 通常已经连续 push 了 start、text_start 和 text_delta。消费者需要经过多次 Promise continuation 才能依次读到这些事件;只让出一个 microtask,producer 可能仍会过早恢复并推入下一段内容。
生成成功响应
现在实现最核心的 produceSuccessResponse()。它把高层 AssistantContent[] 翻译成底层 StreamEvent:
ts
async function produceSuccessResponse(
response: Extract<NormalizedResponse, { type: "success" }>,
model: Model,
chunkSize: number,
now: () => number,
stream: ReturnType<typeof createAssistantMessageEventStream>,
signal: AbortSignal | undefined,
progress: ProducerProgress,
): Promise<void> {
for (const [contentIndex, block] of response.content.entries()) {
if (isAborted(signal)) {
pushFailure(stream, model, now, progress, "aborted", "Faux request aborted");
return;
}
switch (block.type) {
case "text": {
stream.push({ type: "text_start", contentIndex });
let partial = "";
progress.activePartial = { type: "text", text: partial };
for (const delta of splitIntoChunks(block.text, chunkSize)) {
stream.push({ type: "text_delta", contentIndex, delta });
partial += delta;
progress.activePartial = { type: "text", text: partial };
await yieldToConsumer();
if (isAborted(signal)) {
pushFailure(stream, model, now, progress, "aborted", "Faux request aborted");
return;
}
}
stream.push({ type: "text_end", contentIndex, content: block.text });
progress.completedContent.push({ type: "text", text: block.text });
progress.activePartial = undefined;
break;
}
case "thinking": {
stream.push({ type: "thinking_start", contentIndex });
let partial = "";
progress.activePartial = { type: "thinking", thinking: partial };
for (const delta of splitIntoChunks(block.thinking, chunkSize)) {
stream.push({ type: "thinking_delta", contentIndex, delta });
partial += delta;
progress.activePartial = { type: "thinking", thinking: partial };
await yieldToConsumer();
if (isAborted(signal)) {
pushFailure(stream, model, now, progress, "aborted", "Faux request aborted");
return;
}
}
stream.push({ type: "thinking_end", contentIndex, content: block.thinking });
progress.completedContent.push({ type: "thinking", thinking: block.thinking });
progress.activePartial = undefined;
break;
}
case "tool_call": {
stream.push({
type: "tool_call_start",
contentIndex,
id: block.id,
name: block.name,
});
const argumentsJson = JSON.stringify(block.arguments);
if (argumentsJson === undefined) {
throw new TypeError("Faux tool arguments must be JSON-serializable");
}
for (const argumentsDelta of splitIntoChunks(argumentsJson, chunkSize)) {
stream.push({ type: "tool_call_delta", contentIndex, argumentsDelta });
await yieldToConsumer();
if (isAborted(signal)) {
pushFailure(stream, model, now, progress, "aborted", "Faux request aborted");
return;
}
}
stream.push({ type: "tool_call_end", contentIndex, toolCall: block });
progress.completedContent.push(block);
break;
}
}
}
if (isAborted(signal)) {
pushFailure(stream, model, now, progress, "aborted", "Faux request aborted");
return;
}
const message = createSuccessMessage(model, response.content, response.stopReason, now());
stream.push({ type: "done", reason: response.stopReason, message });
}
这段代码虽然较长,但三个分支遵循同一个流程:
text
检查取消
-> 发送内容块 start
-> 将完整内容分成多个 delta
-> 每个 delta 后让消费者运行并再次检查取消
-> 发送内容块 end
-> 把完整内容记入 completedContent
文本分支会产生:
text
text_start -> text_delta... -> text_end
thinking 分支会产生:
text
thinking_start -> thinking_delta... -> thinking_end
工具调用分支会产生:
text
tool_call_start -> tool_call_delta... -> tool_call_end
工具参数要先对完整对象调用一次 JSON.stringify(),再分割得到的字符串。单个 argumentsDelta 很可能只是半截 JSON,这是正常的;不能对每个 delta 单独调用 JSON.parse()。完整字符串会在 tool_call_end 时由上一篇的状态机统一核对。
所有内容块完成后,producer 创建最终成功消息并发送 done。事件里的 message 也就是 stream.result() 最终返回的消息,因此不会出现"事件是一份结果,result() 又是另一份结果"的分叉。
实现 Provider 工厂
最后实现公开的 createFauxProvider():
ts
export function createFauxProvider(options: FauxProviderOptions): FauxProviderHandle {
const chunkSize = options.chunkSize ?? 4;
if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
throw new RangeError("chunkSize must be a positive integer");
}
const now = options.now ?? Date.now;
const responses = options.responses.map(normalizeResponse);
const model = FAUX_MODEL;
const provider: Provider = {
id: "faux",
name: "Faux Provider",
models: [model],
stream(requestedModel, _context, streamOptions) {
const response = responses.shift() ?? {
type: "failure",
errorMessage: "No faux response scripted",
};
const stream = createAssistantMessageEventStream();
const progress: ProducerProgress = { completedContent: [] };
queueMicrotask(() => {
stream.push({ type: "start" });
if (isAborted(streamOptions?.signal)) {
pushFailure(stream, requestedModel, now, progress, "aborted", "Faux request aborted");
return;
}
if (response.type === "failure") {
pushFailure(stream, requestedModel, now, progress, "error", response.errorMessage);
return;
}
void produceSuccessResponse(
response,
requestedModel,
chunkSize,
now,
stream,
streamOptions?.signal,
progress,
).catch((cause: unknown) => {
try {
pushFailure(stream, requestedModel, now, progress, "error", "Faux producer failed");
} catch {
stream.fail(cause);
}
});
});
return stream;
},
};
return {
provider,
model,
pendingResponses() {
return responses.length;
},
};
}
下面逐段理解这个工厂。
校验 chunkSize
ts
const chunkSize = options.chunkSize ?? 4;
if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
throw new RangeError("chunkSize must be a positive integer");
}
分块大小必须是正整数。0 会让切片循环永远无法前进,小数和负数也没有明确含义,因此工厂在开始工作前直接拒绝它们。
复制并规范化队列
ts
const responses = options.responses.map(normalizeResponse);
map() 会创建新数组,同时补齐每条成功响应的 stopReason。faux 内部随后只操作自己的队列,不会对调用方传入的原数组执行 shift()。
在同步阶段取走响应
ts
const response = responses.shift() ?? {
type: "failure",
errorMessage: "No faux response scripted",
};
每调用一次 stream(),就立即从队首取出一条响应,这就是 FIFO(first in, first out,先进先出)。shift() 必须放在 stream() 的同步部分,不能移到 queueMicrotask() 中。否则两个连续调用都可能在 producer 尚未启动时看到同一队列状态,响应归属会变得不清楚。
当队列耗尽时,Provider 不抛出同步异常,也不返回一个永远不结束的空流,而是生成固定失败响应:
text
No faux response scripted
这样调用方仍然通过正常的流协议收到 start -> error,不会因为忘记多写一条脚本而永久等待。
统一发送 start
ts
queueMicrotask(() => {
stream.push({ type: "start" });
// 后续分支
});
无论成功、脚本失败、队列耗尽还是调用前已经取消,第一条事件都必须是 start。这是上一篇状态机已经确定的协议。
预取消也会得到:
text
start -> error(aborted)
而不是只有一条孤立的 error。
区分协议内失败与基础设施失败
脚本失败、队列耗尽和用户取消都属于模型协议能够表达的失败。它们通过 error 事件结束,result() 会正常 resolve 一个 FailedAssistantMessage。
只有 producer 自己出现了无法安全转换成事件的程序错误时,才会最终调用:
ts
stream.fail(cause);
此时事件迭代和 result() 都会 reject。这个区别很重要:以后 Agent 可以区分"模型给了一个失败结果"和"底层流本身已经损坏"。
producer 的 catch 会先尝试生成一条固定文本的协议内错误:
text
Faux producer failed
它不会把任意异常内容直接暴露给上层。只有连这条错误事件都无法通过状态校验时,才退回 stream.fail(cause)。
从 ai 包入口导出
修改:
text
packages/ai/src/index.ts
加入类型导出:
ts
export type { FauxProviderHandle, FauxProviderOptions, FauxResponse } from "./providers/faux.ts";
再加入值导出:
ts
export { createFauxProvider } from "./providers/faux.ts";
公开入口只暴露调用方真正需要的工厂和三个类型。固定模型常量、分块函数、进度对象和消息构造函数都是内部实现细节,不应该成为包的长期公共契约。
本项目源码中的相对导入统一写 .ts。TypeScript 的 rewriteRelativeImportExtensions 会在构建时把扩展名改写成适合 JavaScript 产物的形式,因此这里不要自行改成与现有入口不一致的 .js。
完整使用示例
现在可以从 @di-code/ai 的公共入口创建 faux provider:
ts
import { createFauxProvider } from "@di-code/ai";
const faux = createFauxProvider({
responses: [
{
type: "success",
content: [
{ type: "thinking", thinking: "先读取文件" },
{
type: "tool_call",
id: "call-1",
name: "read",
arguments: { path: "README.md" },
},
],
},
{
type: "failure",
errorMessage: "Model unavailable",
},
],
chunkSize: 4,
now: () => 1_700_000_000_000,
});
console.log(faux.pendingResponses()); // 2
const firstStream = faux.provider.stream(faux.model, { messages: [] });
console.log(faux.pendingResponses()); // 1
for await (const event of firstStream) {
console.log(event);
}
const firstMessage = await firstStream.result();
console.log(firstMessage.stopReason); // "tool_use"
第一条响应包含工具调用,因此没有显式填写 stopReason 时会自动得到 tool_use。faux 只生成工具调用,并不会执行 read。
第二次调用会取得 failure:
ts
const secondStream = faux.provider.stream(faux.model, { messages: [] });
for await (const event of secondStream) {
console.log(event.type); // start、error
}
const secondMessage = await secondStream.result();
console.log(secondMessage.stopReason); // "error"
console.log(secondMessage.errorMessage); // "Model unavailable"
第三次调用时队列已经为空,将得到 No faux response scripted,但流仍然会正常结束。
取消一条正在输出的响应
调用方通过 AbortController 发出取消信号:
ts
const controller = new AbortController();
const faux = createFauxProvider({
responses: [
{
type: "success",
content: [{ type: "text", text: "这是一段会被中途取消的文本" }],
},
],
chunkSize: 2,
});
const stream = faux.provider.stream(
faux.model,
{ messages: [] },
{ signal: controller.signal },
);
for await (const event of stream) {
if (event.type === "text_delta") {
controller.abort();
}
}
const message = await stream.result();
console.log(message.stopReason); // "aborted"
取消后不会继续发送 text_end 或 done,而是发送 error(aborted)。最终失败消息会保留取消前已经输出的文本片段。
代码没有读取 signal.reason 并把它直接写进公开错误信息。reason 可以是任意值,甚至可能含有不应该暴露的数据。本项目统一使用稳定文本:
text
Faux request aborted
格式化与构建
完成代码后,在项目根目录运行:
powershell
Set-Location D:\pi\di-code
npx --no-install biome check --write packages\ai\src\providers\faux.ts packages\ai\src\index.ts
npm run check
npm run build --workspace @di-code/ai
biome check --write 会按项目固定规则格式化本篇修改的两个源码文件。npm run check 应该没有 TypeScript 或 Biome 错误,AI workspace 构建也应该成功。
还可以检查构建后的声明入口是否包含四个公共符号:
powershell
Select-String -Path packages\ai\dist\index.d.ts -Pattern 'createFauxProvider|FauxResponse|FauxProviderOptions|FauxProviderHandle'
四个名称都应该能够匹配到。
总结
这一篇实现了一个完全位于 @di-code/ai 中的可脚本化 faux provider:
FauxResponse用高层内容描述成功或失败,不允许调用方随意拼接底层事件。createFauxProvider()返回配套的 provider、固定 model 和队列状态。- 响应队列按 FIFO 消费,每次
stream()在同步阶段立即取走一条。 - 文本、thinking 和工具调用都会被转换成合法的 start、delta、end 事件。
- 固定
chunkSize、可注入now和全零 usage 让运行结果保持确定。 queueMicrotask()保证stream()同步返回,而 producer 异步工作。AbortSignal通过 producer 主动检查实现协作式取消。- 文本和 thinking 取消时保留安全片段,未完成的工具参数不会进入失败消息。
- 脚本失败、队列耗尽和取消通过协议内
error结束;只有流基础设施故障才使用stream.fail()。
至此,packages/ai 已经拥有一条完整而确定的模型事件来源。下一阶段实现 Agent Loop 时,Agent 只需要依赖标准 Provider 接口,不需要知道事件来自 faux provider 还是真实大模型。
本章节git分支地址:qddidi/di-code at 0810/faux-provider
如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金