【π】一次 LLM 调用,如何变成完整的 Agent Run?

21 分钟

上一篇 我们看到了 pi-mono 的全景:packages/ai 负责一次无状态的 LLM(大语言模型,核心能力是根据输入文本预测并生成回复)调用,packages/agent 负责驱动从用户输入到 agent idle 的完整循环,packages/coding-agent 负责一个有状态的编码会话。一次请求从用户输入出发,穿过这三层,最终回到用户面前。

这一篇聚焦前两层:packages/aipackages/agent,看看从"一次 LLM 请求发出",到"整个 agent 循环跑完"是怎么设计的。

一次 LLM 调用的封装

packages/ai 的签名可以用一句话概括:

(Model, Context, Options) → AssistantMessageEventStream

它接收模型配置、对话上下文(system prompt + 消息历史 + 工具定义)、运行时选项,返回一个增量事件流。

不执行工具不做循环不维护状态。调用结束,一切结束。

Tool 类型在这一层只有 schema(name、description、parameters JSON Schema),没有 execute 方法。“工具长什么样”是 packages/ai 关心的,“工具怎么执行”不是。

工具调用(tool call)是什么:LLM 在生成文本的过程中,能够在特定位置输出一段结构化的工具调用指令(如 {name: "bash", args: {...}})。LLM 只需要知道有哪些工具可以调用、参数格式是什么,至于工具的实际执行逻辑,那是上层的事。

多 Provider 场景的统一之战

pi-mono 支持 10+ 个 provider:Anthropic、OpenAI、Google、Azure、Mistral、GitHub Copilot、Ollama 等等。每家的 API 格式不同,认证方式不同,甚至同一个 API 协议下不同 URL 的行为也不同。packages/ai 的核心工作之一就是把这些差异消化掉,对外暴露统一的接口。

内部架构分四层: packages/ai 四层架构

工厂入口 stream.ts 的实现很简洁:

function resolveApiProvider(api: Api) {
    const provider = getApiProvider(api);
    if (!provider) {
        throw new Error(`No API provider registered for api: ${api}`);
    }
    return provider;
}

export function streamSimple<TApi extends Api>(
    model: Model<TApi>,
    context: Context,
    options?: SimpleStreamOptions,
): AssistantMessageEventStream {
    const provider = resolveApiProvider(model.api);
    return provider.streamSimple(model, context, options);
}

model.api 是一个字符串标识(如 "anthropic-messages""openai-completions"),registry 根据它找到对应的 provider adapter,调用 adapter 的 streamSimple 方法。

Registry 的注册用了编译期类型约束:registerApiProvider 要求传入的 stream 函数类型必须与 api 参数匹配,类型不对编译就报错。运行时是一个普通的 Map,编译期是严格的类型门卫。

懒加载是另一个值得注意的设计。10+ 个 provider 的 adapter 不在启动时全部 import,而是用 createLazyStream 包装,第一次调用时才动态加载。如果你只用 Anthropic,Google 和 Ollama 的代码永远不会被加载。

每个 provider adapter 负责三件事:

  • convertTools():把统一的 Tool[] 翻译成该 provider 的 tool schema 格式
  • convertMessages():把统一的 Message[] 翻译成该 provider 的消息格式
  • buildParams():组合最终的 HTTP 请求体

但同一个 API 协议(如 "openai-completions")下,不同 URL 的行为可能不一样。比如 openai.commax_completion_tokens 字段,groq.commax_tokens;有的 provider 要求 tool result 带 name 字段,有的不需要;thinking 块的格式各家也不统一。

这些差异通过 OpenAICompletionsCompat 接口打补丁:

interface OpenAICompletionsCompat {
    maxTokensField?: "max_completion_tokens" | "max_tokens";
    requiresToolResultName?: boolean;
    requiresThinkingAsText?: boolean;
    thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template";
    reasoningEffortMap?: Partial<Record<ThinkingLevel, string>>;
    supportsReasoningEffort?: boolean;
    supportsStrictMode?: boolean;
    // ...
}

每个 Model 对象可以携带一个 compat 字段,adapter 在构建请求时根据这些补丁调整行为。大部分字段有基于 URL 的自动检测,compat 是手动覆盖的入口。

这个设计把“API 协议级别的差异”(adapter 处理)和“同一协议下不同 URL 的行为差异”(compat 补丁处理)分成了两层,避免了为每个 URL 都写一个完整的 adapter。

抽象之后的 provider 工厂很简单。这里额外说一下,pi 使用 models.dev 的数据更新 provider 数据。

export function registerBuiltInApiProviders(): void {
	registerApiProvider({
		api: "anthropic-messages",
		stream: streamAnthropic,
		streamSimple: streamSimpleAnthropic,
	});

	registerApiProvider({
		api: "openai-completions",
		stream: streamOpenAICompletions,
		streamSimple: streamSimpleOpenAICompletions,
	});
    ...
}

事件流里的 12 种信号

外层消费者(WebUI、agent loop)需要边生成边渲染:看到前几个 token 就开始显示,而不是等完整结果。所以 packages/ai 不返回一个完整的 AssistantMessage,而是返回一个增量事件流。

12 种事件分两组:

生命周期事件(各出现一次):start 表示流开始,done 表示正常结束,error 表示异常结束。

内容块事件(每个内容块一组 start/delta/end):文本块 text、思考块 thinking、工具调用块 toolcall

完整的类型定义:

type AssistantMessageEvent =
    | { type: "start"; partial: AssistantMessage }
    | { type: "text_start"; contentIndex: number; partial: AssistantMessage }
    | { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
    | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
    | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
    | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
    | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
    | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
    | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
    | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
    | { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
    | { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };

所有 delta 事件都带一个 partial 字段,表示当前累积到此刻的完整 AssistantMessage。外层拿到 text_delta 时,既可以只用 delta 做增量渲染,也可以用 partial 拿到完整的中间状态。

一个 Anthropic 请求的事件传递看起来像这样:

SDK 事件                        packages/ai 事件
─────────────────────────────────────────────────────
message_start               →  start { partial }
content_block_start(text)   →  text_start { contentIndex: 0 }
content_block_delta(text)   →  text_delta { delta: "我来帮" }
content_block_delta(text)   →  text_delta { delta: "你查" }
content_block_stop          →  text_end { content: "我来帮你查" }
content_block_start(tool)   →  toolcall_start { contentIndex: 1 }
content_block_delta(input)  →  toolcall_delta { delta: '{"arg' }
content_block_delta(input)  →  toolcall_delta { delta: 'ument' }
content_block_stop          →  toolcall_end { toolCall: {...} }
message_delta(stop_reason)  →  done { reason: "toolUse" }

每个 provider 的 SDK 事件格式不同,adapter 把它们翻译成统一的 12 种事件。上层代码不需要关心底层用的是 Anthropic 还是 OpenAI。

还有一个设计细节:错误编码进流,不抛异常。请求失败、运行时错误都通过 error 事件交付,不会 throw。这保证了事件流的契约始终成立:消费者用 for await 迭代,最后一定能拿到 doneerror,不需要额外的 try/catch。

EventStream:push 与 pull 的握手协议

AssistantMessageEventStream 是这一层最重要的设计之一。

为什么需要流式返回:如果等 LLM 生成完整个回复再返回,用户就只能干等——看不到前几个 token,必须等完整结果出来才有反馈,体验很差。外层消费者(WebUI、agent loop)需要边生成边渲染,所以不返回完整的 AssistantMessage,而是返回增量事件流。

怎么设计:producer 是一个 SSE 流,是 push-based 的(LLM 一吐就推过来),consumer 是一个 for await,是 pull-based(主动要下一个)。两端节奏不一致时,谁先到谁要等:producer 快就要缓冲,consumer 快就要挂起等待。公共 EventStream 用一个 queue 数组承接多出来的 provider 事件,再用一个 waiting 数组记录挂起的 consumer。

让我们来看代码,EventStream<T, R> 是一个泛型类,T 是流式事件类型,R 是最终结果类型。核心只有 60 多行,consumer 端 for await 取事件,producer 端 push 推事件:

class EventStream<T, R = T> implements AsyncIterable<T> {
    private queue: T[] = [];
    private waiting: ((value: IteratorResult<T>) => void)[] = [];
    private done = false;

    private readonly isComplete: (event: T) => boolean; // 外部定义
    private readonly extractResult: (event: T) => R;    // 外部定义
    ...
}

需要考虑两种情况:

Path A:producer 更快。产生数据时发现没有消费者在等,事件进 queue 数组存起来,等待被消费。

Path B:consumer 更快。消费者来消费的时候发现现在没有数据且流未结束,注册 waiting 等待数据生成。等生产者产生数据时发现有 waiter,就能直接发给消费者,事件零缓冲地交付。

// producer
push(event: T): void {
    if (this.done) return;                             // done 后静默丢弃

    if (this.isComplete(event)) {                      // 终止事件
        this.done = true;
        this.resolveFinalResult(this.extractResult(event));
    }

    const waiter = this.waiting.shift();               // 有消费者在等吗?
    if (waiter) waiter({ value: event, done: false }); // Path A 直接交付
    else this.queue.push(event);                       // Path B 没人等,进队列,等待被消费
}
// consumer
async *[Symbol.asyncIterator](): AsyncIterator<T> {
    while (true) {
        // Path A:队列有数据吗?有就直接取
        if (this.queue.length > 0) {
            yield this.queue.shift()!;              
        } 
        // 流已结束
        else if (this.done) {
            return;                                 
        } 
        // Path B:现在没有数据且未结束,注册 waiting 等待数据生成
        else {
            const result = await new Promise<IteratorResult<T>>(
                (resolve) => this.waiting.push(resolve)
            );
            if (result.done) return;
            yield result.value;
        }
    }
}

字段 done 的语义 需要留意,表示 Stream 结束发出了终止事件(如 doneerror event)。Stream 关闭后,后续所有事件都会被静默丢弃。

result() 方法则提供了另一种使用方式:它返回一个 Promise,在 Stream 终止时 resolve。同一个 EventStream 实例既支持流式消费(for await),也支持一次性获取最终结果(await stream.result())。

基础的 EventStream 讲完了,接下来就是 AssistantMessageEventStream 这个泛型类的预配置子类:

class AssistantMessageEventStream
    extends EventStream<AssistantMessageEvent, AssistantMessage> {
    constructor() {
        super(
            // isComplete,判断 stream 是否结束
            (event) => event.type === "done" || event.type === "error",
            // extractResult,提取 stream 的最终结果,类型为 AssistantMessage
            (event) => {
                if (event.type === "done") return event.message;
                if (event.type === "error") return event.error;
                throw new Error("Unexpected event type for final result");
            },
        );
    }
}

这个设计最漂亮的地方在于跨层复用

同样的 EventStream 泛型,在 packages/ai 层桥接 LLM provider 和 agent loop:

// packages/ai 层:provider push 事件,agent loop 消费
EventStream<AssistantMessageEvent, AssistantMessage>

packages/agent 层桥接 agent loop 和 UI/CLI:

// packages/agent 层:agent loop push 事件,UI/CLI 消费
EventStream<AgentEvent, AgentMessage[]>

同一个 60 行的泛型类,桥接了整个架构的两层边界。

stream 与 streamSimple

packages/ai 暴露了两个入口函数:streamstreamSimple。区别在于对 thinking/reasoning 参数的处理方式。

stream 暴露 provider 的原始参数:如果你想精确控制 Anthropic 的 thinking.budget_tokens 或 OpenAI 的 reasoning_effort,直接使用这个入口。但这意味着调用方必须知道自己在跟哪个 provider 对话。

streamSimpleStreamOptions 基础上多了两个统一字段:

interface SimpleStreamOptions extends StreamOptions {
    // "minimal" | "low" | "medium" | "high" | "xhigh"
    // 是不是很熟悉 *^o^* ,openclaw 中的定义就是从这来的
    reasoning?: ThinkingLevel;         
    thinkingBudgets?: ThinkingBudgets;
}

调用方说“我要 medium 级别的 reasoning”,streamSimple 内部根据 provider 类型自动翻译:对 Anthropic 映射成 thinking.budget_tokens,对 OpenAI 映射成 reasoning_effort,对不支持 thinking 的 provider 静默忽略。

packages/agent 默认用 streamSimple,因为 agent 层不应该关心底层用的是哪个 provider。streamSimple 提供了统一的 reasoning 参数映射,agent 只需说“我要 medium reasoning”而无需翻译成具体 provider 的参数名。

从输入到 idle 的 agent run

先定义两个概念:

turn = 一次 assistant response + 它触发的那一轮的工具调用和结果;

agent run = 从用户输入到 agent idle 的完整循环,由一个或多个 turn 串起来。

packages/agent 接管 agent run 的全过程。它负责循环控制、工具执行、event emit。

AgentContext:agent run 的输入快照

agent run 的输入是一个 AgentContext,它是一个精简的上下文快照:

interface AgentContext {
    /** 系统提示词 */
    systemPrompt: string;
    /** 对话历史,模型能看到的所有消息 */
    messages: AgentMessage[];
    /** 本次 run 可用的工具列表 */
    tools?: AgentTool<any>[];
}

三个字段覆盖了 agent 运行所需的全部输入:模型看到什么提示词、看到什么对话历史、能调用什么工具。它不知道 session 是什么不知道 compaction 是什么不知道 system prompt 怎么构建。这些都是外层的职责。

边界清晰了,那么 AgentTool 具体如何补足可执行性?

AgentTool:从 schema 到可执行

packages/aiTool 只有描述性质的 schema:name、description、parameters。LLM 需要看到的就是这些。

packages/agentAgentToolTool 基础上补足了可执行性:

interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
    /** 人类可读标签,UI 显示用 */
    label: string;
    /** 执行前的参数预处理/兼容处理 */
    prepareArguments?: (args: unknown) => Static<TParameters>;
    /** 实际执行逻辑 */
    execute: (
        toolCallId: string,
        params: Static<TParameters>,
        signal?: AbortSignal,
        onUpdate?: AgentToolUpdateCallback<TDetails>,
    ) => Promise<AgentToolResult<TDetails>>;
}

三个新增能力:

  • execute 是核心,接收 toolCallId 和校验后的参数,返回执行结果。signal 用于中断,onUpdate 用于流式输出中间结果(比如 bash 命令的实时输出)。
  • prepareArguments 在 schema 校验前对原始参数做预处理。LLM 有时候会输出格式略偏的参数(比如字符串化的 JSON),这个钩子让工具有机会修正。
  • label 是给人看的。LLM 看到的是 name(如 "bash"),用户在 TUI 里看到的是 label(如 "Execute bash command")。

runLoop:一次 run 里的多个 turn

在深入代码前,先理解两个队列的语义:

  • steering 是“agent 还在干活时就追加输入/任务”。等当前正在执行的工具跑完后触发一次新的 turn。它不会打断正在执行的工具调用,但会让 agent 多跑一个 turn。
  • follow-up 是“agent 本来活全部干完了准备要停了,但在这个时候追加输入/任务”。它在 agent 即将 idle 时被检查,如果有消息就再触发一次新的 turn

注意:两者都会触发新的 turn,真正的差别是检查时机。用户在 agent 执行过程中又输入了一句话,这句话会以 user message 形式加进消息队列,但是具体进哪个队列是可以根据 agent 的配置来改变的。

runLoop 是 agent 的核心驱动逻辑,一个双重 while 结构:

// 外层:follow-up 消息驱动新 turn
while (true) {
    let hasMoreToolCalls = true;

    // 内层:工具调用 + steering 消息驱动新 turn
    while (hasMoreToolCalls || pendingMessages.length > 0) {
        // 注入 pending 消息(来自 steering)
        if (pendingMessages.length > 0) {
            for (const message of pendingMessages) {
                currentContext.messages.push(message);
            }
            pendingMessages = [];
        }

        // 发给 LLM,拿到回复
        const message = await streamAssistantResponse(/*...*/);

        // 检查是否有工具调用
        const toolCalls = message.content.filter(c => c.type === "toolCall");
        hasMoreToolCalls = toolCalls.length > 0;

        if (hasMoreToolCalls) {
            const toolResults = await executeToolCalls(/*...*/);
            for (const result of toolResults) {
                currentContext.messages.push(result);
            }
        }

        // 查 steering 队列
        pendingMessages = (await config.getSteeringMessages?.()) || [];
    }

    // 内层退出 = 没有 tool calls 且没有 steering 消息
    // 查 follow-up 队列
    const followUpMessages = (await config.getFollowUpMessages?.()) || [];
    if (followUpMessages.length > 0) {
        pendingMessages = followUpMessages;
        continue;  // 回到外层,开始新 turn
    }

    break;  // 完全退出
}

内层 while 连续运行多个 turn。每轮迭代就是一个 turn:发起一次 LLM 调用、处理这次返回的工具调用、然后检查 steering 队列。只要模型还在调工具、或者 steering 队列里有新消息,内层就进下一个 turn。turn_start/turn_end 事件就是对应这里每一次 turn 的起止。

外层 while 在内层彻底退出后检查 follow-up 队列。内层退出意味着:最近的一个 turn 已经结束、模型没再调工具、steering 也空,agent 可以准备 idle 了。这时 follow-up 给了一次追加任务的机会,有 follow-up messages 就让内层继续跑下一个 turn,没有的话这次 agent run 就正式结束了,等待下一轮用户输入。agent_start/agent_end 事件就是对应这里每一次 agent run 的起止。

并发启动,按序回收

当一个 assistant 消息包含多个 tool calls 时,默认采用并行执行:

// 全部同时启动
const runningCalls = runnableCalls.map((prepared) => ({
    prepared,
    // 非阻塞异步调用
    execution: executePreparedToolCall(prepared, signal, emit),
}));

// 按原始顺序逐个 await
for (const running of runningCalls) {
    const executed = await running.execution;
    results.push(
        await finalizeExecutedToolCall(/*...*/ running.prepared, executed, /*...*/),
    );
}

并发启动按序回收

多个工具调用同时执行,但结果必须按调用顺序返回。这是因为大部分 provider 需要组装 tool call 和 tool call result 交给 LLM,工具结果的顺序必须和工具调用的顺序一致,才能让 LLM 正确理解哪个结果对应哪个调用.这样实现同时满足了执行效率(工具并行跑)和语义正确性(结果按序返回)。

为什么每层都需要定义自己的消息类型?

packages/aiMessage 只有三种角色:userassistanttoolResult,这是 LLM 能理解的全部。但应用层可能需要自定义消息类型,比如 UI 通知、artifact 展示、状态标记。这些消息不应该发给 LLM,但需要存在于对话历史中。这和传统开发过程中定义 DTO、BO、DO 的思路类似:不同层有不同的关注点和职责范围,消息类型也应该根据层的职责来定义。

// packages/ai
type Message = UserMessage | AssistantMessage | ToolResultMessage;

// packages/agent
type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
interface CustomAgentMessages {
	// Empty by default - apps extend via declaration merging
}

CustomAgentMessages 是一个空接口,应用层通过 TypeScript 的声明合并来扩展自己的字段:

declare module "@mariozechner/agent" {
    interface CustomAgentMessages {
        // example
        artifact: ArtifactMessage;
        notification: NotificationMessage;
    }
}

这样 AgentMessage 就自动包含了 ArtifactMessageNotificationMessage

两层消息模型意味着需要两条转换流水线:

  1. transformContextAgentMessage[] → AgentMessage[]。不改变消息类型,改变消息内容或数量。典型用途是 context window 管理,对话太长时裁掉旧消息。
  2. convertToLlmAgentMessage[] → Message[]。跨越类型边界,把应用层消息翻译成 LLM 能认的格式。自定义消息要么转成 user message,要么直接过滤掉。

agent run 的 hook:callback 的边界

packages/agent 定义了循环的骨架,但循环中每个关键节点的具体行为(消息怎么转、模型认证、消息插队、工具能不能跑、上下文管理等等)通过 AgentLoopConfig 定义的 callback 全部委托给上层决定。这个分界让 packages/agent 可以被不同的上层复用(不只是 coding-agent,也可以是 chat-agent、data-agent)。

Callback作用
convertToLlmAgentMessage[] → Message[],跨层消息转换
transformContextAgentMessage[] → AgentMessage[],context 窗口管理
getApiKey动态获取 API key(OAuth token 可能过期)
getSteeringMessages获取 steering 队列的消息
getFollowUpMessages获取 follow-up 队列的消息
beforeToolCall工具执行前拦截(可阻止执行)
afterToolCall工具执行后修改结果

这 7 个 callback 就是 packages/coding-agent 注入具体行为的接口。packages/agent 不知道也不需要知道 packages/coding-agent 的存在。它只知道有一组 callback,调用它们,拿到结果,继续循环。

AgentEvent 生命周期

packages/agent 通过观察者模式向外发射事件。事件分四类:

type AgentEvent =
    // Agent 生命周期
    | { type: "agent_start" }
    | { type: "agent_end"; messages: AgentMessage[] }
    // Turn 生命周期
    | { type: "turn_start" }
    | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
    // Message 生命周期
    | { type: "message_start"; message: AgentMessage }
    | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
    | { type: "message_end"; message: AgentMessage }
    // Tool 执行生命周期
    | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
    | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
    | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };

agent_end 是最后一个被 emit 的事件,但不意味着 agent 立刻 idle。所有订阅了 agent_end 的 listener 都 settle 之后,agent 才真正进入 idle 状态。这让 listener 有机会在 agent 结束后做清理工作(比如持久化),而 agent 会等它们全部完成。

一次 agent run 的完整事件流

application(coding-agent)          packages/agent                 packages/ai
────────────────────────────────────────────────────────────────────────────
    │                                     │                          │
    │ agent.prompt(...) ─►runAgentLoop ─► │                          │
    │                                     │                          │
    │ ◄────── agent_start ────────────────│                          │
    │ ◄────── turn_start ─────────────────│                          │
    │    * user message *                 │                          │
    │ ◄────── message_start ──────────────│                          │
    │ ◄────── message_end ────────────────│                          │
    │                                     │ ── streamSimple(...) ──► │
    │    * assistant message1 *           │                          │
    │ ◄────── message_start ──────────────│ ◄────── start ────────── │
    │ ◄────── message_update ─────────────│ ◄────── text_start ───── │
    │ ◄────── message_update ─────────────│ ◄────── text_delta ───── │
    │                                     │ ◄────── text_end ─────── │
    │                 .                   │ ◄────── thinking_start ─ │
    │                 .                   │ ◄────── thinking_delta ─ │
    │                 .                   │ ◄────── thinking_end ─── │
    │                                     │ ◄────── toolcall_start ─ │
    │                                     │ ◄────── toolcall_delta ─ │
    │ ◄────── message_update ─────────────│ ◄────── toolcall_end ─── │
    │ ◄────── message_end ────────────────│ ◄────── done ─────────── │
    │                                     │                          │
    │ ◄────── tool_execution_start ───────│                          │
    │ ◄────── tool_execution_update ──────│                          │
    │ ◄────── tool_execution_end ─────────│                          │
    │    * toolResult message *           │                          │
    │ ◄────── message_start  ─────────────│                          │
    │ ◄────── message_end   ──────────────│                          │
    │ ◄────── turn_end ───────────────────│                          │
    │                                     │                          │
    │ ◄────── turn_start ─────────────────│ messages with toolResult │
    │                                     │ ── streamSimple(...) ──► │
    │    * assistant message2 *           │                          │
    │ ◄────── message_start ──────────────│ ◄────── start ────────── │
    │ ◄─────  message_update ─────────────│ ◄────── text_start ───── │
    │ ◄─────        ...      ─────────────│ ◄────── text_delta ───── │
    │ ◄─────  message_update ─────────────│ ◄────── text_end ─────── │
    │ ◄─────  message_end ────────────────│ ◄────── done ─────────── │
    │ ◄─────  turn_end ───────────────────│                          │
    │ ◄─────  agent_end ──────────────────│                          │

无状态意味着什么

packages/aipackages/agent 合在一起,完成了从“用户输入”到“agent idle”的全过程。但它们是无状态的:一旦结束就真的结束了,不持久化任何东西,不记得上一次对话的内容、跑了什么工具、得出什么结论。这些"记忆"相关的问题,都是外层 packages/coding-agent 的工作。

下一篇,我们聚焦到应用层。

目录