从零开发一个 Coding Agent(七):实现纯文本 Agent Loop

本篇文章是《从零开发一个 Coding Agent》系列第七篇。在上一篇中,我们实现了一个可脚本化的 Faux Provider。它能够像真实大模型一样,持续产生 starttext_deltadone 等事件。

不过,Provider 只负责"模型说了什么",并不知道这些内容应该怎样变成 Agent 对外暴露的生命周期事件。例如:

  • 用户消息什么时候进入本轮对话?
  • 模型正在生成的半句话怎样交给终端实时显示?
  • 模型生成完成后,最终的 AssistantMessage 放到哪里?
  • 请求失败或被取消时,外层调用者怎样知道这一轮已经结束?

这些问题都由 Agent Loop(Agent 循环) 负责。

这一篇先实现最小版本:接收一条用户消息,只调用一次 Provider,把 Provider 的事件流转换成 Agent 的事件流,最后得到完整的消息数组。工具执行、连续多轮调用、Agent 类和 CLI 都留到后续文章。

Agent Loop 到底是什么

可以把一次模型请求想成去餐厅点餐:

  1. 用户消息是顾客写下的菜单。
  2. Provider 是厨房,负责一道道把菜送出来。
  3. StreamEvent 是厨房内部的出餐通知,例如"开始制作""先送来一部分""全部完成"。
  4. Agent Loop 是传菜员,它读取厨房通知,再转换成前台能理解的服务流程。
  5. 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

sequenceDiagram participant C as 上层调用者 participant L as agentLoop participant P as Provider participant I as Provider EventStream participant O as Agent EventStream C->>L: UserMessage + AgentContext L-->>C: 立即返回 Agent EventStream L->>P: stream(model, context, signal) P-->>L: Provider EventStream I-->>L: start / text_delta / done L->>O: agent_start / message_update / agent_end O-->>C: for await...of 读取 AgentEvent O-->>C: result() 得到 Message[]

内层流由 Provider 产生:

ts 复制代码
EventStream<StreamEvent, AssistantMessage>

它的终止事件是 doneerror,最终结果是一条 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。如果为了省事填入假的 usagestopReason,上层代码就无法区分"正在生成"和"已经完成"。

因此定义一个预览类型:

ts 复制代码
export interface AssistantMessagePreview {
	readonly role: "assistant";
	readonly provider: string;
	readonly model: string;
	readonly text: string;
}

预览消息只服务于实时显示,不进入正式对话历史。最终消息只能从 Provider 的 doneerror 事件中取得。

定义 AgentEvent

Provider 的 startdoneerror 是内层流的控制事件,不应该直接作为 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" 后,就知道当前事件一定含有 messageevent 字段。

一次正常请求会发生什么

假设用户输入:

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_deltatext_starttext_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 还要做三件事:

  1. 把最终 AssistantMessage 加入消息数组。
  2. 发出 message_endturn_end
  3. 发出携带完整结果的 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 的 doneerror 都带有最终 AssistantMessage

ts 复制代码
if (event.type === "done" || event.type === "error") {
	terminalMessage = event.message;
}

这个最终对象已经包含:

  • 完整的 content
  • providermodel
  • token usage
  • timestamp
  • stopReason
  • 失败时的 errorMessage

因此,正式历史必须使用它,不能把只含累计文本的 preview 塞进消息数组。

处理 Provider 直接抛错

规范内的模型失败会产生 error 事件。但传输层或第三方 Provider 也可能在创建流或迭代流时直接抛出异常,例如:

ts 复制代码
stream() {
	throw new Error("transport broke");
}

Agent Loop 不应该因此永远停在"正在生成"状态。它需要把异常转换成一条失败的 AssistantMessage,然后照常发出 message_endturn_endagent_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);
});

这个测试同时证明了四件事:

  1. Agent 生命周期顺序固定。
  2. Provider 的文本分片最终被合成为 hello
  3. 外层 result() 返回用户消息和最终助手消息。
  4. 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_deltaab。测试收到它后立刻取消,最终消息只能保留已经产生的 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 的核心职责可以概括为四句话:

  1. 为一次请求建立稳定的 Agent 生命周期。
  2. 消费 Provider 的细粒度事件,并投影为上层可理解的 AgentEvent。
  3. 用 preview 表示生成中的内容,用 AssistantMessage 表示最终结果。
  4. 保证成功、错误和取消最终都收敛到 agent_end

到这里,我们已经拥有了一个可以由 Faux Provider 驱动的纯文本 Agent Loop。它还不会执行工具,也没有长期状态,但最重要的控制流骨架已经建立起来。后续功能会在这条稳定骨架上逐层增加,而不是重新发明另一套消息和事件机制。

本章节git分支地址:qddidi/di-code at 0810/faux-provider

如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金

相关推荐
大家的林语冰1 小时前
👉 尤雨溪再次成立新公司,同时官宣 Pinia 4 正式发布!
前端·javascript·vue.js
恒拓高科WorkPlus1 小时前
从“可选项”到“必选项”——私有化部署即时通讯如何重塑企业协作新底座
大数据·网络·人工智能
浮生望1 小时前
React useRef 深度解析:从DOM操作到Web Worker多线程协作
前端
Hello_Damon_Nikola1 小时前
扰流筋从原理到应用
人工智能·单片机·嵌入式硬件
某林2121 小时前
从“仿真能跑”到“真机能走”:一套轮腿机械狗强化学习系统的工程化实践
人工智能·stm32·嵌入式硬件·架构·机器人·机械狗
浮生望1 小时前
React Router v7 实战全景:路由配置、嵌套路由、鉴权守卫与懒加载完整指南
前端
用户938515635071 小时前
从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战(二)
前端·javascript·typescript
爱知菜1 小时前
Transformer vs Diffusion:为什么扩散模型更擅长捕捉细节?
人工智能·深度学习·transformer·扩散模型
xp_xht1231 小时前
Day1 概率机器学习 信息论
人工智能·机器学习·信息论