从零开发一个 Coding Agent(六):实现一个可脚本化的 Faux Provider

本篇文章是《从零开发一个 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()
逐条读取事件       获取最终消息

具体依赖关系是:

flowchart LR Script["FauxResponse 脚本队列"] --> Provider["Faux Provider"] Provider --> Producer["异步事件生产器"] Producer --> Stream["EventStream 与状态校验器"] Stream --> Events["逐条 StreamEvent"] Stream --> Result["最终 AssistantMessage"] Events -. "后续 Task 消费" .-> Agent["Agent Loop"]

这里有一条很重要的边界: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);
}

这看起来像是矛盾,其实表示两个不同阶段:

  1. stream() 先同步创建并返回一个空的事件通道。
  2. Provider 在后台异步向通道中 push() 事件。
  3. 调用方通过 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[]。如果调用方可以随便拼事件,就很容易写出缺少 startdelta 顺序错误或没有终结事件的非法流。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_starttext_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.reasonmessage.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 了 starttext_starttext_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_enddone,而是发送 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 - 东方小月的专栏 - 掘金

相关推荐
用户938515635074 分钟前
Type vs Interface:读完这篇就没有面试官能难倒你了
前端·面试·typescript
用户9385156350714 分钟前
手写一个 LLM Harness 框架:用工程化手段把大模型幻觉踩在脚下
javascript·人工智能·后端
前端开发江鸟17 分钟前
我能解释 RAG、MCP 和 Eval,却画不出一条完整的 Agent 链路
人工智能
油丶酸萝卜别吃1 小时前
jquery-ajax.js 说明文档
前端·javascript·jquery
windliang1 小时前
Claude Code 源码分析(九):子 Agent 如何分叉、继续与回到父会话
前端·javascript·面试
ivywriter1 小时前
【具身智能】物理AI具体指什么,和具身智能是什么关系?
人工智能
new_zhou1 小时前
C++ 项目 AI 协作指南(Windows / MSVC 环境)
c++·人工智能·windows
洛阳泰山1 小时前
AI 应用层被 Python 卷成红海,为什么我偏要用 Java 造一个 RAG + 工作流引擎?
java·人工智能·后端
wangray1997droid2 小时前
让 AI 拥有“真实记忆“:一次从碎片到叙事的记忆系统质变
人工智能
一次旅行2 小时前
AI 前沿日报 | 2026年08月08日 星期六
人工智能