剥开 Agent 开发:参照 pi-agent 实现一个小 Agent

你用过 Codex 或 Claude Code 吗?在终端输入一句「这个服务偶发空指针,帮我定位并修复」,然后看着它自己搜索代码、打开文件、输出分析、动手修改、跑测试、汇报结果------像有一个不知疲倦的同事坐在旁边干活。它底下到底发生了什么?模型只是个文本接口,它怎么知道要搜索?文件是谁打开的?测试是谁跑的?谁决定干完了可以收工?

这篇文章就是把这台机器拆开给你看。参考答案是 pi-agent------一个核心循环极简(agent-loop + agent 约 1370 行 TS)但功能完备的生产级 agent 框架;本文用 Java 重新实现它的 agent 层(pi-agent-java 学习移植项目),逐个机制拆开讲:协议层怎么把模型的流式输出变成事件,循环怎么把事件串成会话,工具怎么被编排,运行怎么被打断,行为怎么被定制。

先说文体:这是一份带讲解的架构文档------先给架构事实(类、方法、事件名都是真实存在的),再讲设计动机(为什么这么做、不这么做会怎样)。不是教程,不带你从零敲代码;但每一段都对应仓库里可以打开的源码。素材范围说清:本文覆盖 ai(协议层)与 core(运行时)两层,pi-agent 的第三层 harness------AgentHarness 应用外壳,包含会话持久化、上下文压缩、技能系统、提示词模板、执行环境等------在后续文章展开。

阅读地图:一个引子(Agent 是什么、循环是什么),六个问题(每个问题一章,对应 agent 工程的一块主干),一个收束(回望全景、展望缺口)------

  • 引子:Agent 与 Loop Engineering
  • 问题①:不用框架,跑通工具调用最少要写什么?
  • 问题②:模型的输出是一段一段到的,怎么消费?
  • 问题③:发起一次 Agent 会话,内部到底发生了什么?
  • 问题④:工具调用,怎么执行才又快又稳?
  • 问题⑤:agent 跑起来了,怎么打断它、给它加活?
  • 问题⑥:不改循环的代码,怎么改变循环的行为?
  • 收束:小的造完了,大的缺什么

这六个问题不是并列的------它们构成一条架构依赖链:流式消费(②)是细粒度观察的前提,细粒度观察(③)是精确控制的前提,精确控制(④⑤)是行为定制(⑥)的前提。pi-agent 的架构正是沿着这条链逐层搭建的:每一层只依赖下一层暴露的接口,不跨越也不回绕,层层递进共同构成一个完整 agent。

第一章 引子:Agent 与 Loop Engineering

架构定位 :全文架构总图------ai(协议层)/ core(运行时)/ harness(外壳)三层划分,依赖方向 core → ai 单向(ai 可独立复用)。 本章设计思想:Agent 的本质是状态外挂------LLM 是无状态的,运行时在 LLM 外部维护全部状态,每次调用时注入快照。

1.1 一句话说清 Agent,与它的三个部件

你在终端输入需求,Agent 自己搜索代码、读文件、改代码、跑测试------它底下到底发生了什么?答案只有一句话:

Agent 就是让 LLM 在循环里使用工具的运行时。 模型决定做什么(「我应该先搜索」),代码替它执行(真的去搜),结果回填上下文(搜索结果进历史),如此往复直到任务完成。pi-agent 的实现中,Agent 类是一个有状态包装器------它持有对话 transcript,管理 steering(打断)/followUp(排队)消息队列,订阅事件流,底层调用 agentLoop 函数驱动 Reason(LLM 调用)与 Act(工具执行)的循环。

三个部件各司其职:

  • LLM :推理引擎------LLM 本身是无状态的,每次 API 调用互相独立,全靠历史消息撑起连续性。它不执行任何东西,只产出「下一步该干什么」的判断(含一段自然语言或一批工具调用)。
  • 工具:行动能力------搜索、读文件、改代码、跑测试,每个工具是一段普通代码加一份给模型看的说明书(名字、描述、参数 Schema------第五章会看到这份说明书决定调用质量)。
  • 循环:把两者串成「自主行为」的胶水------没有循环,LLM 只能一问一答;有了循环,它才能多步推进。搜索是模型决定的(Reason),文件是工具代码打开的(Act),收工是模型看到测试结果后不再调用工具、循环检测到这一点退出的(Observe 之后的 Reason)。

这里藏着 pi-agent 的第一个核心设计原则:状态外挂。LLM 无状态不是需要绕过的缺陷,而是被主动利用的约束------运行时在 LLM 外部持有全部状态(transcript、工具列表、系统提示词),每次调用 LLM 时注入当前快照。状态外挂意味着状态可以被任意修剪、替换、分叉------这正是后面所有上下文管理机制(transformContext / convertToLlm 钩子、harness 的会话持久化)的基础。

模型世界的另一个基本约束也从这里出发:上下文窗口------历史越长 token 越贵,超过窗口直接报错,所以上下文管理不是优化项,是生存项(后续章节的钩子为此预留)。

1.2 ReAct:循环的行为模式

上面那个「决定 → 执行 → 回填 → 再决定」的节拍有个名字:ReAct(Reason + Act)。10 行伪代码看出形状:

text 复制代码
while true:
    decision ← LLM(历史, 工具说明书)          # Reason
    if decision 不含工具调用: return decision  # 任务完成
    result ← 执行(decision.工具调用)           # Act
    历史.append(decision, result)             # Observe

口径说明一句:ReAct 是学术界对这类代码模式的概念命名------pi-agent 的 AgentLoop 就是这段伪代码的工程化完整版实现。

1.3 Loop Engineering:循环的工程学

伪代码只有 10 行,工程化的循环要回答的问题却有一排:输出怎么流式消费(第三章)?一次会话内部发生什么(第四章)?工具怎么编排(第五章)?怎么打断和追加(第六章)?怎么不改代码定制行为(第七章)?------这五个问题,就是第 3 到第 7 章的目录。循环不是一行 while(true),是一门工程开发的学问。

1.4 pi-agent 的答案地图

pi-agent 把实现切成三层,依赖单向、各层可独立理解:

flowchart TB subgraph E[&#34;外部生态(本文不覆盖)&#34;] CA[&#34;pi-coding-agent<br/>编码场景实现&#34;] TUI[&#34;pi-tui<br/>终端 UI&#34;] PROTO[&#34;pi-server<br/>服务端运行时&#34;] end subgraph H[&#34;harness 外壳(结尾展望,本文不覆盖)&#34;] HS[&#34;AgentHarness 主类 / session 会话持久化<br/>compaction 上下文压缩 / skills 技能系统<br/>system-prompt 系统提示词<br/>prompt-templates 提示词模板 / env 执行环境<br/>tools 内置工具集(read/write/edit/bash...)&#34;] end subgraph C[&#34;core 运行时(本文重点)&#34;] AL[&#34;Agent 有状态包装器&#34;] AG[&#34;AgentLoop 双层循环&#34;] AT[&#34;AgentTool 工具契约 + AgentLoopConfig 钩子体系&#34;] end subgraph A[&#34;ai 协议层(第三章)&#34;] API[&#34;多 Provider API 适配(SSE 流解析)&#34;] ES[&#34;EventStream 事件流&#34;] UT[&#34;类型系统与工具函数&#34;] end E -->|&#34;可选接入&#34;| H H -->|&#34;调用&#34;| C -->|&#34;调用&#34;| A

pi-agent 的完整生态不止这三层。在外围还有三个独立包:pi-coding-agent (编码场景的完整 CLI 应用,含会话管理、TUI 交互、扩展系统)、pi-tui (终端交互界面)、pi-server(服务端运行时)------它们各自独立,任何一层都可以被单独替换或复用。本文只取 ai + core 两层切片------它们是所有场景共享的核心骨架,其余层是场景定制。

  • ai(协议层):跟模型 API 打交道------多 Provider 适配(SSE 流解析)、事件流、类型系统与工具函数。不依赖任何 agent 概念,可以单独拿去做聊天应用。
  • core(运行时) :agent 的心脏------循环、工具编排、事件、外壳、AgentLoopConfig 钩子体系。依赖 ai,不依赖 harness。核心抽象是事件驱动的对话循环------AgentLoop 驱动 Reason(LLM 调用)与 Act(工具执行)的往复,每次状态变更都通过结构化事件暴露,UI、扩展、遥测都是事件的订阅者,彼此不感知。
  • harness(外壳):产品化的那一层------AgentHarness 应用外壳,包含会话持久化、上下文压缩、技能系统、提示词模板、执行环境、内置工具集(tools)等。

第二章从这张地图的左下角开始:不用任何框架,把循环的最小骨架亲手写一遍。

第二章 问题①:不用框架,跑通工具调用最少要写什么?

架构定位 :ai.types 模块------两层架构的公共语言:模型世界的类型词汇。 本章设计思想:类型即契约------ai 层与 core 层之间只通过类型通信,不共享实现代码;sealed interface 是编译器级的契约保障。

第一章说 Agent 是「让 LLM 在循环里使用工具的运行时」。现在把这句话翻译成代码------不用任何框架,用最朴素的方式写一个能跑工具调用的循环,看看最少要多少东西。

2.1 最小循环,先跑起来

约定一个工具:search(query) 搜索代码。循环本体长这样:

text 复制代码
tools = [search 的定义: 名字 + 说明书 + 参数 Schema]
history = [user("这个服务偶发空指针,帮我定位并修复")]

while true:
    # 1. 组装上下文,调用模型
    response ← LLM(systemPrompt, history, tools)

    # 2. 模型返回------看方向盘和内容
    response ← LLM(systemPrompt, history, tools)
    if response.stopReason in ["error", "aborted"]:
        break                              # 异常退出

    # 3. 检查是否有工具调用(实际依据是 content 中有 toolCall 块)
    if 没有 toolCall 块:
        print(response.text)               # 模型觉得活干完了(实际 API 中文本在 content 数组的 TextContent 块中)
        break

    # 4. 执行工具调用
    for call in response.toolCalls:
        result ← 执行(call.name, call.arguments)
        history.append(toolResult(call.id, result))   # 每个 toolResult 带自己的 toolCallId

    # 5. assistant 消息已在流式过程中逐步推入 history,回到第 1 步

不到 20 行,但它是一个真的 Agent:模型决定调 search(Reason),代码执行搜索(Act),结果回填历史(Observe),下一轮模型看到搜索结果继续决定------1.3 的 ReAct 就这么转起来了。逐段看三个关键点:

第 1 步,每次都带全量历史。 LLM 无状态(1.2 的第一个约束),循环每轮把整个 history 发过去------systemPrompt 也是每次调用都携带的,不是只发一次。历史就是模型的全部记忆,丢一条它就忘一件事。这也是为什么后面所有「上下文管理」都围绕 history 做文章。

第 2 步,方向盘 + 内容双重判断。 先看 stopReason:error/aborted 直接退出,length 时工具调用被标记失败而不执行。再看 assistant 消息的 content 中有没有 toolCall 块------有则继续,无则收工。stopReason 和 content 各管一路:前者管异常,后者管正常流转。

第 4 步,执行是本地代码的事。 模型只发出调用请求(工具名 + 参数 JSON),真正去搜代码的是你的 Java 代码------模型从不清行代码,它只「点菜」。

第 5 步,回填要成对。 assistant 消息已在流式过程中逐步推入 history;每个 toolResult 带着自己的 toolCallId 回填------下一轮模型要同时看到「我要了什么」和「拿到了什么」,缺前者它不知道结果哪来的,缺后者它不知道搜过。这对对应关系后面还会反复见到(第五章整批工具结果按原始顺序入列,就是为了保它)。

StopReason 是循环的方向盘。 模型每次返回都带一个停下来的理由:stop(正常说完,没有工具调用)、toolUse(我要调工具,别停)、length(输出被截断)、error(模型侧失败)、aborted(被取消)。第六个值 pending 不是停下的理由,而是流式中间态------消息尚未收尾时的占位,§3.2 会看到翻译层怎么处置它。循环先看方向盘:error 或 aborted 直接退出;length 时工具调用被标记失败而不执行(参数可能不完整)。然后检查 assistant 消息的 content 中有没有 toolCall 块------有则继续转,无则收工。1.1 的第三个悬念在这里解开:收工不是谁批准的,是模型不再要求调工具、方向盘指向 stop,循环顺势退出。

现实对照:Java 项目里的 Main 类就是这个骨架的最小可运行版本------配置模型和 API key、装上 read_file 工具、订阅事件、发一句 prompt。用 Agent 封装后的 Quick Start 更能看出「种子发芽」的样子(此处省略了 streamFn 的配置------它指定循环用哪家协议实现调用模型,TS 版中为必填项、默认实现经全局注册器解析,见 §7.2;完整代码见 Main.java):

java 复制代码
Agent agent = new Agent(new AgentOptions()
        .model(model)
        .apiKey(apiKey)
        .tools(List.of(readFileTool))
        .systemPrompt("You are a helpful assistant."));

agent.subscribe((event, signal) -> {
    if (event instanceof AgentEvent.MessageUpdate mu
            && mu.assistantMessageEvent() instanceof AssistantMessageEvent.TextDelta td) {
        System.out.print(td.delta());      // 逐字打印
    }
});

agent.prompt("Hello!").join();

运行它,终端逐字 打出回答------第一章那句定义「让 LLM 在循环里使用工具的运行时」,第一次以可运行代码的形态发芽。值得多看一眼的是它的形态:AgentOptions 构造时注入一切(模型、工具、systemPrompt------这些字段住进 AgentState;而 apiKey 通过 AgentLoopConfig 传递给底层循环,不在 AgentState 中);subscribe 是事件流的入口;prompt().join() 同步等运行结束------不等也行,fire-and-forget 靠事件观察(第六章展开)。

两个观察点埋伏笔:第一个,回答是逐字 出现的------这个「逐字」是大模型天生的特性。第二个,subscribe 拿到的是一串事件 ,不是一个返回值------prompt 的结果不是答案文本,是一场需要订阅的事件流(意味着什么,第四章拆)。

2.2 模型世界的词汇表------类型即契约

最小循环能跑,靠的是一组和模型对话的词汇。模型只认识三样东西:

词汇 是什么
消息(3 类) user(用户/应用输入)、assistant(模型响应,含正文与工具调用请求)、toolResult(工具执行结果,与 toolCall 一一对应回填)
内容块(4 类) text / image / thinking / toolCall------消息内容由块组成;注意 image 只在 user 和 toolResult 中出现,assistant 只含 text/thinking/toolCall
StopReason 模型停下的理由------方向盘(2.1 已讲)

三个容易看滑的细节。user 消息不一定是人打的 ------应用代码也可以通过 Agent#steer 或 Agent#followUp 注入。assistant 消息的内容是块列表 ------一次响应可以既有 text 又有 toolCall:模型先说「我先搜一下相关代码」,再发工具调用。toolResult 靠 ID 对应------每个 toolCall 有 ID,toolResult 带着同一个 ID 回填,多工具并发时全靠 ID 配对(第五章会看到顺序为什么也重要)。

classDiagram class Message { <<sealed>> } Message <|.. UserMessage : role=user Message <|.. AssistantMessage : role=assistant Message <|.. ToolResultMessage : role=toolResult class AssistantMessage { List~ContentBlock~ content StopReason stopReason } class ContentBlock { <<sealed>> } ContentBlock <|.. TextContent ContentBlock <|.. ImageContent ContentBlock <|.. ThinkingContent ContentBlock <|.. ToolCall

这组类型的真正角色是 ai 层与 core 层之间的契约------ai 层负责从模型响应的字节流中翻译出这些类型,core 层负责消费它们驱动循环。两层之间只通过这组类型通信,换掉 ai 层的 Provider 实现或换掉 core 层的循环逻辑,另一边完全不受影响。

pi-agent 侧的细节不在本章展开:Tool(给模型看的声明)与 AgentTool(给循环用的执行定义)的辨析见 §5.1,core 侧的 AgentMessage 与这组 LLM 消息的互译见 §7.3------现在只需要知道词汇表有两层,本章是模型那一层。

2.3 五十行循环的五个缺口

骨架跑通了,但拿它去写产品,第一个用户就会教做人。五十行循环(伪代码 20 行 + 工具定义 + 胶水代码)有五个缺口,恰好是后文五章的目录:

  • ① 不流式 :LLM() 一调用就阻塞到模型回完------生成 30 秒的回答,用户盯 30 秒空白。体验问题,但也是商业问题:用户等不到第一句就关掉了。
  • ② 不可观察:循环内部一无所知------UI 想画进度条、日志想记轮次、trace 想串调用链,全都无从下手。生产环境出事故,你连它在第几轮、调了什么工具都查不到。
  • ③ 工具裸奔:参数没校验(模型幻觉出不存在的参数,工具直接崩)、没有拦截(谁都能调任何工具,权限呢?)、没有并发(三个独立工具串行干等)、错误约定混乱(工具失败了返回错误文本,模型当作正常输出继续用)。
  • ④ 不可控:一旦跑起来外部没有把手------不能打断(模型跑偏只能杀进程)、不能加活(想补句话只能重开)、不能取消(成本失控时停不下来)。
  • ⑤ 不可塑:上下文修剪、历史压缩、自定义消息------想改任何行为都得动循环本体的代码。每个产品的策略都不同,硬编码就是死路。
flowchart LR G[&#34;五十行循环&#34;] --> N1[&#34;① 不流式&#34;] --> C3[&#34;第三章<br/>流式消费&#34;] G --> N2[&#34;② 不可观察&#34;] --> C4[&#34;第四章<br/>会话全景与事件&#34;] G --> N3[&#34;③ 工具裸奔&#34;] --> C5[&#34;第五章<br/>工具编排&#34;] G --> N4[&#34;④ 不可控&#34;] --> C6[&#34;第六章<br/>打断与追加&#34;] G --> N5[&#34;⑤ 不可塑&#34;] --> C7[&#34;第七章<br/>扩展机制&#34;]

五个缺口是依赖递进的台阶。接下来五章,一层一层补。

第三章 问题②:模型的输出是一段一段到的,怎么消费?

架构定位 :ai 包的 events / api / util 模块------协议层管线:从 HTTP 字节流到结构化流事件。 本章设计思想:流式不是模型的特性,是 HTTP 的诚实利用;协议层与运行时层之间只通过类型契约通信;差异外置------主干代码只处理标准协议,Provider 差异通过配置注入。

2.3 的缺口①还立着:模型没回完,用户只能盯着空白。但 2.1 的 Quick Start 里有个现象值得回头一看------回答是逐字打出来的。这个「逐字」不是模型施舍的特效,是一整条消费链路撑起来的。这一章把链路拆开:字节流怎么变成事件、事件怎么交给消费者、以及协议层不体面的那些脏活。

3.1 SSE:模型的输出怎么到达

向模型要流式输出,只需要在请求里多带一个参数:

text 复制代码
POST /v1/chat/completions
{"model": "...", "messages": [...], "stream": true}

── 响应(HTTP 200, Content-Type: text/event-stream)──
data: {"id":"chatcmpl-x","choices":[{"index":0,"delta":{"content":"你"}}]}
data: {"id":"chatcmpl-x","choices":[{"index":0,"delta":{"content":"好"}}]}
data: {"id":"chatcmpl-x","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]

协议叫 SSE (Server-Sent Events):一条不关闭的 HTTP 连接,服务端每生成一点就推一行 data: {...},直到 [DONE] 收尾。没有魔法------流式不是模型的特性,是 HTTP 的一次诚实利用:响应体本来就可以不一次给完。

值得看清的是每个 data: 行的结构:它是一个独立完整的 JSON ,不是把大 JSON 切开分片。每行自带 choices 结构,delta 只携带这一步的增量------两个字的文本、半个参数片段、或者一个 finish_reason。

为什么要流式?工程上的账是:总耗时不变,首 token 时间大幅提前------用户感知的是「开始得到回答」的时刻,不是「完整拿到回答」的时刻。剩下的问题是:这些一行行的 JSON,怎么消费?

3.2 把字节流翻译成事件

SSE 报文是原始素材,离「能消费」还差一层翻译。两个麻烦:其一,报文里的工具调用参数是逐字符的 JSON 片段 ------{"pa、th": "/tm、p/a.txt"},拼不齐就是乱码;其二,消费者关心的不是报文结构,是语义------「文本开始了吗」「工具调用攒齐了吗」。

翻译的产出是 AssistantMessageEvent,共 12 种,分两层设计:

  • 生命周期层 (3 种):start(消息开始)、done(携带完整 AssistantMessage 的终态)、error(流中途出错)。提供消息级边界------消费者据此知道一条消息的开始和结束。
  • 内容块层 (9 种):text_start / text_delta / text_end、thinking_start / thinking_delta / thinking_end、toolcall_start / toolcall_delta / toolcall_end。提供块级粒度------消费者据此知道文本何时开始、每个增量是什么、何时结束。

两层合在一起构成完整的流式消费协议。更重要的是,每种事件都携带一个 partial ------当前 AssistantMessage 的快照。翻译层维护状态机拼装内容,消费者拿到的永远是当前消息的全貌,不需要自己维护任何中间状态。增量到快照,消费者永远不需要自己拼状态 。快照里的方向盘也在拼装:stopReason 初始为 pending(尚未停下),收到 finish_reason 才落成终态;流结束仍停在 pending,说明服务端没有正常收尾------翻译层报错,把消息转成 aborted/error 终态,残缺状态绝不冒充正常收尾。

翻译层的容错也有讲究:data: 前缀之外的行(空行、keep-alive 注释)直接跳过;每行读完检查一次取消信号,收到取消就抛异常、由 try-with-resources 关闭 reader 顺带断开 HTTP 连接------请求中断不是等流自己流完,是立刻掐断。至于工具参数为什么必须逐片段:模型生成参数时也是逐 token 的,一段上百行的 JSON 要么等完再给(回到非流式),要么逐片段给------流式的代价就是拼接,这个活只能留在翻译层。

整条翻译管线,从一行行报文到一个个事件:

flowchart LR subgraph 原始层[&#34;SSE 报文(一行行 JSON)&#34;] D1[&#34;data: {delta:{content:&#34;你&#34;}}&#34;] D2[&#34;data: {delta:{content:&#34;好&#34;}}&#34;] D3[&#34;data: [DONE]&#34;] end subgraph 翻译层[&#34;OpenAiCompletionsApi#doStream 主循环&#34;] P[&#34;按 delta 内容路由<br/>文本→text_delta<br/>工具参数→toolcall_delta<br/>[DONE]→done&#34;] end subgraph 事件层[&#34;AssistantMessageEvent(12 种,各带 partial 快照)&#34;] E1[&#34;text_delta&#34;] --> E2[&#34;text_delta&#34;] --> E3[&#34;done(终态)&#34;] end D1 --> P D2 --> P D3 --> P P --> E1 P --> E2 P --> E3

3.3 EventStream:极简的异步通道

事件有了,怎么交给消费者?pi-ai 的答案是 EventStream------不到百行的类,两个核心方法,AssistantMessageEventStream 只是它的一个特化(终止条件 = 收到终态事件,最终结果 = 完整消息):

java 复制代码
public void push(T event) {
    if (done) return;
    if (isComplete.test(event)) {          // 终止事件
        done = true;
        finalResult.complete(extractResult.apply(event));
    }
    if (!subscribers.isEmpty()) {
        subscribers.get(0).accept(event);  // 有订阅者:同步直调
    } else {
        queue.add(event);                  // 无订阅者:入队等待
    }
}

public void subscribe(Consumer<T> consumer) {
    while (!queue.isEmpty()) {             // 先排空历史事件
        consumer.accept(queue.remove(0));
    }
    if (done) {
        consumer.accept(null);             // 结束信号
    } else {
        subscribers.add(consumer::accept); // 注册后续事件
    }
}

两个方法各自的分支走向:

flowchart TD subgraph push[&#34;push(event) --- 生产者侧&#34;] A{done?} -->|是| A1[忽略] A -->|否| B{终止事件?} B -->|是| B1[&#34;finalResult.complete<br/>done = true&#34;] B -->|否| C{订阅者存在?} B1 --> C C -->|是| C1[&#34;同步回调订阅者&#34;] C -->|否| C2[入队 queue] end subgraph sub[&#34;subscribe(consumer) --- 消费者侧&#34;] D[&#34;排空 queue(先补历史)&#34;] --> E{done?} E -->|是| E1[&#34;回调 null(结束信号)&#34;] E -->|否| E2[注册进 subscribers] end

两个设计决策值得停一秒。

其一,push-based 换 pull-based。 TS 用 for await...of 拉取事件,Java 没有等价的异步迭代器,改为 subscribe 回调推送。代价是语义换位------「消费者来拉」变成「生产者来推」,但先排空队列再注册的顺序保证了不丢事件,done 后补发 null 结束信号,语义等价。

顺带看 queue 和 finalResult 各自的用处。queue 解决时序解耦 :生产者先于订阅者到达时,事件入队等人来取------「先生产后订阅」不丢数据。而且「先排空再注册」的顺序不能反:若先注册再排空,新事件会经 subscribers 直调插队到历史事件前面,消费顺序变成 e3、e1------乱序不是并发才有的病,路径顺序颠倒了也会得 。finalResult 提供第二粒度:不想逐事件处理的消费者,可以只 await 最终结果------一套事件,两种消费粒度(第四章会看到 Agent 两种都用)。

其二,背压不在 EventStream 里,在调用链上。 注意 push 是同步直调 订阅者回调的------EventStream 自己没有任何缓冲或限速(与 TS 一致)。那慢消费者怎么防止事件堆积?答案不在 EventStream 里:上层消费者(第四章的 AgentLoop)在转发事件时同步等待下游处理完才继续------这个等待阻塞了事件回调,回调阻塞了 push,push 阻塞了 SSE 读取。慢消费者天然拖慢整条流水线,不需要任何显式限流。背压不是机制,是同步调用链的性质。

3.4 一个实现适配 N 家模型

流式消费写完,下一个现实问题:模型不止一家。OpenAI 兼容 API 已是事实标准------DeepSeek、GLM、Kimi 改个 baseUrl 就能接入。但「兼容」这个词是有陷阱的:兼容是表面的,方言是大量的 。max_tokens 还是 max_completion_tokens?流式响应里带不带 usage?工具调用的参数字段长什么样?------OpenAICompletionsCompat 一个类型就描述了 20+ 个这样的差异点(各 compat 类型合计 30+)。

通病是把这些差异写死在实现里,if-else 越堆越高,每接一家模型改一遍主干。pi-ai 的解法是把差异抽象为配置对象 :OpenAICompletionsCompat / OpenAIResponsesCompat / AnthropicMessagesCompat 各描述一家的方言,OpenAiCompletionsApi#buildParams 构建请求时逐项读配置------compat.maxTokensField() 决定字段名,compat.supportsUsageInStreaming() 决定是否等流尾的 usage。新增一家 Provider,加一份配置,主干代码一行不动。主干还内建了一批跨方言的兼容细节:工具调用 ID 唯一化(复合 ID 附短哈希截断)、缓存标记(cache_control)从 user 消息扩展到 tool 消息、HTTP 客户端可整体注入替换、grammar 约束采样(constrainedSampling)是否启用按 compat 开关------新方言接入时,这些都不必再各写一遍。

compat 配置对象体现的是 差异外置 原则------主干代码只处理标准协议,所有 Provider 特有的行为差异通过配置注入。这个原则在后面还会反复出现:AgentLoopConfig 的钩子体系也是把行为差异外置为配置,而不是硬编码在循环里。

3.5 当 LLM 调用不可靠:三件套

协议层还有一类脏活:LLM API 的失败模式很「个性」。上下文溢出的报错五花八门,各家格式不同,还很少直说「你超了」「出错了」;瞬时错误(限流、超时)值得重试,逻辑错误重试无意义------但通过模型响应很难直接分清这两者。pi-ai 用一组小组件应对:

  • TokenEstimator:用于发请求前本地估算 token,零 API 成本,回答「上下文还剩多少」------溢出最好在发生前知道。
  • ContextOverflowDetector:识别各家溢出报错模式,把「莫名其妙的报错」翻译成「上下文超了」这一明确信号------上层据此触发压缩,而不是傻傻重试。
  • RetryClassifier :判断一个错误是否值得重试,只分类,不重试 ------分类器不越权;执行另有配套件:retryProviderRequest 兜 HTTP 建连级瞬时失败(API 适配器默认启用),retryAssistantCall 对单次调用做有界指数退避(调用方按需选用)。

三件套的共同姿态是 分类先于决策:组件负责把模糊的失败翻译成明确的信号,至于拿信号怎么办------执行在独立的重试组件里,更大的策略空间归调用方。分类错了决策必错,所以分类值得独立成件、单独打磨。具体参数(4 字符 ≈ 1 token 粗算、20+ 种溢出模式匹配、错误消息文本匹配)都是 pi-agent 的工程选择,不同项目可以替换策略,但「分类先于决策」的结构不变。


至此,协议层把「一段一段到达的字节」变成了「结构化的事件流」。协议层和运行时层之间只有一条边界:AssistantMessageEvent 类型------协议层产出 AssistantMessageEventStream,运行时层消费它,两层不共享任何实现代码,只共享类型定义。这是 pi-agent 分层架构的核心纪律:层间通信只通过类型契约,不通过实现依赖。

但事件流只是原料------谁在消费端驱动循环、把一次次模型响应串成完整会话?下一章进入全文枢纽:一次 Agent 会话的内部全景。

第四章 问题③:发起一次 Agent 会话,内部到底发生了什么?

架构定位 :core.loop(AgentLoop)------运行时心脏。它把前两章的零件装进一个双层循环,负责轮次(Turn)生命周期、工具编排入口与全部事件出口。本章只讲它;工具执行的内部在第五章,Agent 外壳在第六章。 本章设计思想:事件驱动而非过程式------循环、工具执行、会话变更全部通过结构化事件暴露,UI/扩展/遥测都是事件的订阅者,彼此不感知。

上一问解决了「输出怎么消费」------但 EventStream 和那 12 种流事件只是零件。现在把它们全部装进一次真实调用:prompt("Read config.json") 发出之后、agent_end 事件到来之前,内部到底发生了什么?还记得 2.1 那 40 行最小循环吗?真实的 AgentLoop 保留了它的形状,但每一步都长出了「器官」。

4.1 全景时序图

先看全景地图,再逐段走。

sequenceDiagram participant U as 调用方 participant A as Agent<br/>(有状态外壳) participant L as AgentLoop<br/>(双层循环) participant API as OpenAiCompletionsApi<br/>(OpenAI SSE) participant ES as AssistantMessageEventStream participant T as AgentTool U->>A: prompt(&#34;Read config.json&#34;) A->>A: 创建 ActiveRun(重入保护)+ 取消令牌 A->>L: 虚拟线程启动 runAgentLoop L-->>U: agent_start L-->>U: turn_start / message_start·end(user 消息入列) loop 外层循环(followUp 驱动) loop 内层循环(工具调用链 + steering) L-->>U: turn_start(首轮已在 runAgentLoop 发出,此处跳过) L->>L: transformContext → convertToLlm<br/>(AgentMessage[] → Message[]) L->>API: stream(model, context, options) API->>ES: push(Start / TextDelta / ToolCallDelta / Done...) ES-->>L: subscribe 回调(流事件) L-->>U: message_start / message_update / message_end alt stopReason == toolCalls L->>L: prepareToolCall(Schema 校验 + beforeToolCall 闸门) par 并行执行(Virtual Thread) L->>T: execute(toolCallId, args, signal, onUpdate) T-->>U: tool_execution_update(流式进度) end T-->>L: AgentToolResult L->>L: finalizeExecutedToolCall(afterToolCall 闸门) L-->>U: tool_execution_end L->>L: ToolResultMessage 按原始顺序入列 end L-->>U: turn_end L->>L: prepareNextTurn → shouldStopAfterTurn → steering 检查 end L->>L: followUp 队列检查(空则退出) end L-->>U: agent_end A->>A: finishRun()(幂等收尾,activeRun = null)

一次调用分五个阶段。外壳阶段 :Agent#prompt 创建 ActiveRun(运行标记,防重入)和取消令牌,然后在虚拟线程里启动循环------Agent 在这里只做了「开门」和「关门」两件事,中间全程是 AgentLoop 的舞台(外壳细节第六章)。翻译阶段 :每轮调用模型前,历史要过两道钩子------transformContext 管修剪(上下文太长时剪枝),convertToLlm 管翻译(AgentMessage → Message);一个对内整理,一个对外换语,职责不混(4.3 节走流程,第七章讲它们各自能改什么)。流式阶段 :SSE 字节流被翻译成 12 种流事件,AgentLoop 边收边转发------订阅者拿到的不是最终答案,而是一场直播(4.4 节)。工具阶段 :模型决定调工具时,先过两道闸门再并行执行(第五章)。收尾阶段:每轮末尾跑一条检查链,决定继续还是终止(4.5 节)。

图里还有三个值得先记住的观察点。事件贯穿始终 :几乎每个动作都伴随一条 emit 箭头指向调用方------观察循环的窗口全开(4.4 节展开)。工具是独立子系统 :prepareToolCall → execute → finalize 这条支线自成体系(第五章整章)。钩子挂在固定位置:prepareNextTurn、shouldStopAfterTurn 出现在每轮末尾的固定点上(第七章)。

4.2 双层循环:为什么是两层

双层循环不是语法选择,而是优先级的代码化。

最小循环只有一个 while。真实系统里有两类「继续」:模型还要调工具,是当前任务内部 的继续;用户在任务之外又发来新指令,是新任务的继续。两类语义不同,硬挤在一个循环条件里,迟早要在该停的地方停不下来、在不该停的地方停错。AgentLoop 的答案是拆成两层:

text 复制代码
pending ← 启动时注入 steering 消息
外层 while true:
    hasMoreToolCalls ← 初始化为 true
    内层 while hasMoreToolCalls 或 pending 非空:
        turn_start(首轮已在 runAgentLoop 中发出,此处跳过)
        注入 pending 消息(message_start·end + 入列)
        assistant ← streamAssistantResponse(context)    // 一次 LLM 调用
        若 stopReason ∈ {ERROR, ABORTED}: turn_end + agent_end + 返回
        toolCalls ← 从 assistant.content 提取
        若有 toolCalls:
            batch ← LENGTH 截断 ? 整批标记失败 : executeToolCalls(...)
            hasMoreToolCalls ← !batch.terminate
            toolResults 按原始顺序入列
        turn_end
        prepareNextTurn → shouldStopAfterTurn → steering → 取消(四连检查)
    followUp 队列: 有 → 注入并继续外层 / 无 → 跳出
agent_end

内层条件 hasMoreToolCalls || pending 非空 里藏着两个续命来源。工具链 :hasMoreToolCalls 由工具批次的 terminate 标记反转而来------模型还在指挥,循环就还得转(terminate 什么时候置位,第五章讲)。待注入消息 :steering 和 followUp 消息都汇入 pending,等的就是循环回到头顶把它吃进去。steering 汇入有两个时机:启动时从 getSteeringMessages 钩子捞一次,之后每轮末尾再捞一次------外部想插话,只需把消息放进钩子背后的队列,循环自己会来取(队列实现在第六章)。首轮 turn_start 在循环入口提前发出,内层用 firstTurn 标记避免重复。

为什么 steering 挂内层、followUp 挂外层?打断优先于追加。steering 是「先做完这步,然后改方向」------它要打断当前任务,必须趁循环还在转的时候插进去;followUp 是「这个做完了,还有下一个」------任务本该结束才轮到它。就像改 bug 时产品经理凑过来说话,你得先听完再决定改不改;而修完 bug 才接的新需求,排队天经地义。所有长任务交互系统都要回答「插话和新任务谁优先」,AgentLoop 的回答写进了循环结构里。

4.3 一轮(Turn)的固定节拍

内层循环转一圈,就是一轮(Turn):一次 LLM 调用,加上它触发的工具执行。每一轮的节拍是固定的:

flowchart LR A[turn_start] --> B[注入 pending 消息] B --> C[&#34;流式接收<br/>(transformContext → convertToLlm → SSE)&#34;] C --> D{ERROR / ABORTED?} D -->|是| E[短路返回] D -->|否| F{有 toolCalls?} F -->|是| G[工具执行] G --> H[结果入列] F -->|否| H[turn_end] G --> H H --> I[prepareNextTurn] I --> J[shouldStopAfterTurn] J --> K[steering / 取消检查]

节拍里有两个容易被忽略的细节。流式占位更新 :在 AgentLoop 内部,assistant 消息在 Start 事件到达时就 add 进 context.messages 占了位置,其后每个流事件都原地 set 替换------循环自己用的工作历史里,消息在流式过程中逐步长成。正式历史只在 message_end 追加 :Agent 暴露给外部的 state.messages 则相反------processEvents 只在 message_end 时才把消息 add 进去,流式过程中的 set 只更新 streamingMessage 指针。外部订阅者看到的历史列表里永远只有定稿,没有半成品。两个历史列表各司其职:一个给循环用(随时需要完整上下文),一个给外部看(只交付确认的结果)。

节拍固定是为可扩展性服务的。 prepareNextTurn、shouldStopAfterTurn 这些钩子挂在固定位置,行为才可预测:写压缩的人知道自己的钩子每轮末尾必然被调,写审批的人知道 shouldStopAfterTurn 一定在 steering 检查之前。可预测的节拍是钩子可靠工作的前提(第七章展开)。

节拍中间那步「流式接收」,内部藏着全文最精巧的机制------事件怎么从协议层一路流到你的 UI。

4.4 单轮数据通路 + 事件四层

一条事件从模型到你的屏幕,要走五段路:

flowchart LR API[&#34;OpenAiCompletionsApi<br/>SSE 逐行解析 + Builder 增量组装&#34;] -->|&#34;push(Start / TextDelta / Done...)&#34;| ES[&#34;AssistantMessageEventStream<br/>push / subscribe&#34;] ES -->|&#34;subscribe 回调&#34;| AL[&#34;AgentLoop 订阅回调<br/>流事件 → AgentEvent&#34;] AL -->|&#34;emit(message_start / update / end...)&#34;| AG[&#34;Agent#processEvents<br/>先更新 state,再通知 listeners&#34;] AG --> LS[&#34;外部订阅者&#34;]

AgentLoop 转发出来的,是另一套事件词汇------10 种 AgentEvent,分四层。第三章讲的 12 种 AssistantMessageEvent 是协议层的流事件,到这里被翻译成运行时的生命周期事件:

flowchart LR subgraph L1[Agent 层] A1[agent_start] A2[agent_end] end subgraph L2[Turn 层] T1[turn_start] T2[turn_end] end subgraph L3[Message 层] M1[message_start] M2[&#34;message_update<br/>(携带底层 AssistantMessageEvent)&#34;] M3[message_end] end subgraph L4[ToolExecution 层] E1[tool_execution_start] E2[tool_execution_update] E3[tool_execution_end] end

落到代码就是一组密封接口,节选五种:

java 复制代码
sealed interface AgentEvent {
    record AgentStart() implements AgentEvent { }
    record AgentEnd(List<AgentMessage> messages) implements AgentEvent { }
    record TurnEnd(AgentMessage message, List<ToolResultMessage> toolResults) implements AgentEvent { }
    record MessageUpdate(AgentMessage message, AssistantMessageEvent assistantMessageEvent) implements AgentEvent { }
    record ToolExecutionEnd(String toolCallId, String toolName, Object result, boolean isError) implements AgentEvent { }
    // ...共 10 种:Agent×2 + Turn×2 + Message×3 + ToolExecution×3
}

事件驱动是 pi-agent 的核心设计哲学------循环、工具执行、会话变更全部通过结构化事件暴露,UI、扩展、遥测都是事件的订阅者,彼此不感知。下面这层翻译,就是这套哲学的具体实现。

这不是简单转发,而是粒度翻译。 12 种 AssistantMessageEvent 是协议层的语言,描述「字节怎么到」------TextDelta、ToolCallDelta,全是 delta 级的碎片;10 种 AgentEvent 是运行时的语言,描述「循环走到哪」------轮次开始了、消息定稿了、工具跑完了,全是生命周期级的状态。两层之间的桥是 MessageUpdate 的第二个字段:粗粒度事件里原样携带底层流事件。

为什么非要两层?想象只有一层的两个极端。只发 12 种 delta 事件:UI 得理解协议层全部细节才能画一个进度条,SSE 换个格式全量重写;只发 10 种生命周期事件:进度条好画了,但逐字打字机做不了------中间过程被丢掉了。两层嵌套各取所长:做进度条、日志的订阅者只关心生命周期------订阅 10 种事件就够,不必知道 SSE 是什么;做逐字打字机的订阅者要 delta------从 message_update 的 assistantMessageEvent 字段直接取。MessageUpdate 同时携带 message(当前完整快照)和 assistantMessageEvent(底层增量事件)------想显示当前文本的订阅者直接用 message,想做打字机的取 delta。协议层的复杂性被挡在下面,需要的人又能穿透下去------一套事件,两种消费粒度。

事件到达 Agent 后,Agent#processEvents 有一条铁律:先更新 state,再通知 listeners------同一个事件先落进状态,再广播给外部监听器,同一线程同步执行。listeners 读到的 state 永远是「已含当前事件」的状态,不存在「事件到了、状态还没跟上」的竞态。

还要注意事件是单向广播:只读、不可干预。想控制循环,走钩子(第七章);想注入消息,走队列(第六章)------事件通道一个字都不让你改。

4.5 四条终止路径

2.1 的循环一个 break 就结束。真实系统的「退出」是四个出口的并集:

flowchart TD S[每轮 LLM 调用返回后<br/>/ 内层循环体末尾] --> A{&#34;① stopReason<br/>∈ ERROR / ABORTED&#34;} A -->|是| R1[&#34;turn_end + agent_end<br/>立即返回&#34;] A -->|否| B{&#34;② shouldStopAfterTurn<br/>钩子说停?&#34;} B -->|是| R2[&#34;agent_end<br/>优雅停止&#34;] B -->|否| D[继续下一轮] D --> E{&#34;内层结束<br/>followUp 队列空?&#34;} E -->|空| R4[&#34;agent_end<br/>自然结束&#34;] E -->|有| F[注入 followUp<br/>回到外层]

四条路径各有各的语义。ERROR / ABORTED :模型侧或流式层失败,turn_end + agent_end 立即返回,工具一个不执行------失败消息已入历史,下一轮模型能看见自己倒在哪。shouldStopAfterTurn :优雅停止,钩子说了算------预算花完、轮次到顶、该触发压缩了,都是它。自然结束:无工具调用且 followUp 队列空------最常见也最安静,模型觉得活干完了,循环表示同意。取消信号不设独立的循环末尾检查点------它在下轮 LLM 调用开头或工具执行中被捕获,最终以 ERROR/ABORTED 路径退出。

最后一个反直觉的事实:agent_end 发出,运行还没结束。 agent_end 只是「不会再有循环事件」的声明;此后还有 promise complete、Agent#finishRun 幂等收尾(清 streamingMessage、activeRun 置空),而且 agent_end 的 await 订阅者仍计入结算------waitForIdle 要等它们全部跑完才返回。「完成」的定义,比想象中严格(第六章展开)。


回看图 7:循环的骨架、节拍、事件、出口都拆完了,种子已经长成一棵完整的循环。但图里工具执行那一步------prepareToolCall → execute → finalize------我们始终当成黑盒。下一章拆开它:工具调用,怎么执行才又快又稳。

第五章 问题④:工具调用,怎么执行才又快又稳?

架构定位 :core.loop 的工具编排子系统(executeToolCalls / prepareToolCall / finalizeExecutedToolCall)与 core.types 的 AgentTool 契约------循环的「手」。 本章设计思想:确定性压倒吞吐------模型是概率程序,工具执行管线用校验、闸门、保序回填把概率输出约束为确定性行为。

2.1 的循环里,执行工具只有一行 execute(toolCall)。真实系统里这一行长成了一条管线:先守卫、再预检、后执行、终定稿------从「能执行」到「敢执行」,每一步都在回答同一个问题:模型是概率程序,它的输出凭什么敢直接跑?

5.1 从裸 execute() 说起

先把三个容易混淆的名字摆正------它们在 2.2 词汇表里出现过,到工具这章才真正分家:

  • Tool:能力声明。给模型看的------名字、描述、JSON Schema 参数定义,随上下文发给模型。
  • ToolCall:一次调用请求。模型发的------工具名、参数、调用 ID,出现在 assistant 消息的内容块里。
  • AgentTool :core 侧的执行定义。给Agent循环用的------在 Tool 的声明之外,携带 execute 回调(真正干活的函数)和可选的 executionMode(串行/并行偏好)。

一个工具从「被模型知道」到「被执行」,要依次经过这三个身份。Java 侧没有 TS 的 TypeBox 编译期类型安全,execute 收到的是 Map<String, Object>------所以参数合法性只能靠运行时把关:

  • 参数校验 :ToolArgumentsValidator 按工具自己的 JSON Schema 校验模型给的参数。模型是概率程序,参数可能缺字段、类型错、多出未知属性;校验失败会抛异常,但被 prepareToolCall 统一接住、转成 Immediate 结果------模型拿到的是带 JSON 路径的错误信息(如 params.path: expected string),下一轮自己改对。校验是给模型的纠错通道,不是给程序员的断言。
  • 错误约定 :工具失败要抛异常 ,不要返回错误文本。抛异常会被 executePreparedToolCall 统一接住,在构造 ToolResultMessage 时标记 isError=true,组装成结构化的错误回传;返回错误文本则会被当成正常输出------模型分不清「文件不存在」是失败还是内容。错误也是需要设计的协议字段:模型只有明确知道「这是失败」,才会停止重试、换个思路。

5.2 并行还是串行

一批 toolCall 到手,编排的第一件事不是执行,是守卫:

text 复制代码
若 stopReason == LENGTH:                 # 输出被截断
    整批标记失败,跳过执行               # 参数可能不完整
    错误信息: "response hit the output token limit, arguments may be truncated"
否则:
    若任一工具声明 sequential 或全局配置串行:
        逐个执行                         # 串行
    否则:
        逐个预检(串行),全部通过后并行执行    # Virtual Thread 每任务一线程
        tool_execution_end 按完成序发出
        toolResult 消息按 assistant 原始顺序入列

LENGTH 截断守卫 是最容易漏的一条:模型的输出撞上 max_tokens 上限时,stopReason 是 LENGTH 而非 toolCalls------toolCall 的参数 JSON 可能只有半截。直接执行等于拿残缺参数跑工具,failToolCallsFromTruncatedMessage 把整批标记失败并附上说明,模型下一轮重发完整参数。

为什么默认并行?模型一次发多个 toolCall,本身就是并行意图;而工具多属 IO 型------读文件、查库、调接口,等待占大头、互不干扰,并行收益直接。串行规则是给有状态工具准备的保险:两个工具都要写同一个文件,并行就是竞态,任一工具声明 sequential,整批降级串行------宁可慢,不竞态。

为什么消息必须按原始顺序入列------LLM 要求 toolResult 与 toolCall 一一对应、顺序一致,乱了就是给模型喂脏历史。所以事件可以抢发(谁先完成谁先报,UI 实时),消息必须排队(按 assistant 消息里的原始顺序落历史):

sequenceDiagram participant L as AgentLoop participant A as 工具 A(慢) participant B as 工具 B(快) participant H as 对话历史 par 并行执行 L->>A: execute L->>B: execute end B-->>L: 完成 L-->>L: tool_execution_end(B) ← 事件按完成序 A-->>L: 完成 L-->>L: tool_execution_end(A) L->>H: toolResult(A) 入列 ← 消息按原始序 L->>H: toolResult(B) 入列

确定性压倒吞吐 :宁可牺牲一点「谁快谁先进历史」的自由,也要保证模型每次看到的历史结构一致。TS 实现中,Promise.all 按数组位置等待、天然保序------toolResult 严格按 assistant 消息里的原始顺序入列。Java 移植的并行合并当前是简化实现(Immediate 结果前置、异步结果随后),与 TS 的严格保序尚有差距------一个诚实的移植注脚。

5.3 两道闸门与 Preparation 分支

串行还是并行只解决「怎么跑」,管线真正的心脏是预检。每次工具执行前,prepareToolCall 按顺序跑四步检查:

flowchart LR TC[ToolCall] --> P1{&#34;① 查找工具<br/>按名称匹配&#34;} P1 -->|未找到| IM[Immediate<br/>错误结果] P1 -->|找到| P2{&#34;② prepareArguments<br/>参数适配&#34;} P2 --> P3{&#34;③ Schema 校验<br/>ToolArgumentsValidator&#34;} P3 -->|失败| IM P3 -->|通过| P4{&#34;④ beforeToolCall<br/>钩子&#34;} P4 -->|block=true| IM P4 -->|放行| PR[Prepared<br/>toolCall + tool + args] PR --> EX[executePreparedToolCall<br/>真正执行 + onUpdate 进度] EX --> FIN[finalizeExecutedToolCall<br/>afterToolCall 钩子] FIN --> TR[ToolResultMessage] IM --> TR

四步检查的产出是一个密封类型 Preparation :要么 Prepared(带工具、带校验后的参数,放行执行),要么 Immediate(带现成的错误结果,不进 execute)。这个类型设计值得停一秒:「不执行」不是异常路径,而是一等结果(first-class result)。工具未找到、参数校验失败、被钩子拦截------三种「不执行」殊途同归,都变成一条正常的错误 toolResult 消息入列,循环照常进行。没有 try-catch 满天飞,分支即类型(first-class result)。

四步各有各的把关对象。查工具 :模型可能幻觉出不存在的工具名,找不到就回「Tool not found」;prepareArguments :模型给的参数格式漂移时(字段改名、结构变化),工具可以在这里做兼容适配,比等模型学会新格式快;Schema 校验 :上一节讲过的纠错通道;beforeToolCall:权限与规范的最后一道人工防线,钩子返回后还会检查取消信号------已取消则直接以「Operation aborted」收场,不再往下走。

两道钩子闸门各司其职:

  • beforeToolCall :参数校验通过后、执行前拦截。典型用途是权限校验、路径规范化;block=true 时这一个调用变成错误结果,批次里其他工具照常------拦截是精确制导,不是全批取消。
  • afterToolCall :执行完成后、结果入列前改写。脱敏、审计标记(给 details 塞个 audited: true)、或请求 terminate(下一节);钩子返回的字段非 null 则覆盖(包括 content、details、terminate、isError),null 则保留工具原始结果。这意味着钩子可以把正常结果标记为错误,也可以把错误结果救回来------isError 也是可设计的协议字段。

执行本体 executePreparedToolCall 还有个不起眼的细节:onUpdate 上报的中间结果异步发 tool_execution_update 事件,acceptingUpdates 标志保证工具返回最终结果后,迟到的部分更新被直接丢弃------进度条不会比结果晚到。

5.4 terminate 的保守语义

工具可以请求终止循环:结果里带 terminate: true,示意「跳过后续的 LLM 调用,任务已完成」。但一整批工具调用,什么时候才真的停?

text 复制代码
shouldTerminateToolBatch(finalizedCalls):
    若批次为空: 返回 false
    返回 批次内每个结果的 terminate 全为 true

整批全部 terminate 才停,混合批次继续跑。 为什么这么保守?终止意味着跳过下一轮 LLM 调用------那些没说 terminate 的工具结果,就再也没有机会被模型消费了。模型看不到它们,等于白跑;更糟的是历史里躺着模型没见过回应的 toolCall,对话结构直接损坏。宁可多问一轮模型,不丢任何一份产出------终止的门槛,就是对结果负责的门槛。

terminate 的典型用户是「收尾型」工具:notify_done 这类发完通知就该停的活,不该再问模型「还有什么要做的吗」。它和第四章的 shouldStopAfterTurn 分工不同:terminate 是工具自己 说的(生产者判断任务完成),shouldStopAfterTurn 是外部策略说的(监督者按预算、轮次叫停)------一个在结果里,一个在钩子里,互不干涉。

5.5 解剖一个真实工具

管线讲完了,看一个真实工具长什么样。read_file,取自 AgentTools#createReadFileTool:

java 复制代码
public static AgentTool createReadFileTool(String cwd) {
    // JSON Schema 定义 path/offset/limit 三参数(path 必填)
    String description = "Read the contents of a file. Supports text files and images "
            + "(jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, "
            + "output is truncated to 2000 lines or 50KB (whichever is hit first). "
            + "Use offset/limit for large files. When you need the full file, "
            + "continue with offset until complete.";
    return new AgentTool("read", description, "read", schema,
            (toolCallId, params, signal, onUpdate) ->
                    executeReadFile(cwd, params, signal),
            null, ToolExecutionMode.PARALLEL);
}

三个设计要点,代码不在长短,在意图。description 是写给模型看的说明书 ------它把支持什么、截断规则、大文件怎么续读全写进去了;模型调用质量的好坏,一半取决于这段话写得认不认真。execute 回调 :失败抛异常(文件不存在、路径越界,一律走 5.1 的错误约定,isError=true 回传),模型自己决定是换个路径还是放弃。魔数嗅探而非扩展名:图片检测读文件头做魔数比对,改名图片(.txt 后缀的 PNG)不会被误判为文本------工具内部不信任文件名的诚实。


工具这只手,从裸 execute() 长成了带守卫、闸门、并行编排和保守终止的完整管线。但回头再看图 7------循环跑起来之后,外部依然没有把手:不能打断、不能加活、不能取消。下一章给循环装上控制面:Agent,有状态的外壳。

第六章 问题⑤:agent 跑起来了,怎么打断它、给它加活?

架构定位 :core.agent(Agent / AgentState / PendingMessageQueue / ActiveRun)------有状态外壳与控制面:并发模型、消息队列与取消。 本章设计思想:运行标记即锁------单驱动线程约定消除状态竞态;打断优先于追加------steering 和 followUp 的检查点位置是结构给定的优先级。

2.3 的缺口④:循环一旦跑起来,外部没有把手------不能打断、不能加活、不能取消。第四章看全景时你可能已经注意到,AgentLoop 是一组静态方法,无状态、跑完即散;第五章的工具管线再完善,也只是循环的「手」更稳了。把手得另装------装在一个有状态、有入口的对象上:Agent。

6.1 裸循环没有把手

把 2.1 的 while(true) 直接跑起来,三个问题立刻浮现:

  • 进不去:消息队列?没有。运行中想补一句「顺便也看下 config.yaml」,唯一的办法是等它跑完重新发起------上一次的上下文全丢。
  • 停不下来:取消信号?没有。模型陷入死循环、工具越跑越偏,只能杀进程。
  • 看不到:状态全在局部变量里。跑到第几轮、正在执行哪个工具、流式到哪个字------外部一概不知。

三个问题指向同一个答案:循环需要一个外壳------把状态收进来、把入口开出去、把把手递出来。每个问题各自对应外壳的一项能力:进不去→消息队列(6.3/6.4),停不下来→取消令牌(6.5),看不到→状态快照加事件流(6.2)。

6.2 Agent:循环的有状态外壳

Agent 管理三类状态,各管一件事:

  • AgentState:对话状态------systemPrompt、model、thinkingLevel、tools、messages,加四个运行时观察量:isStreaming(是否运行中)、streamingMessage(流式中的半成品消息)、pendingToolCalls(执行中的工具调用 ID 集合)、errorMessage(最近一次失败的 assistant 消息的错误信息)。
  • 两个 PendingMessageQueue:steering 队列与 followUp 队列------运行中注入消息的两条通道(6.3 / 6.4 展开)。
  • ActiveRun :运行标记------存在即运行中,为 null 才能起新运行。Agent#prompt 开头第一件事就是查它:非 null 直接抛异常,错误信息也不敷衍------「Use steer() or followUp() to queue messages」------它告诉调用者正确的替代路径,而不是只说不行。具体实现因语言而异:TS 用 Promise + AbortController,Java 用 CompletableFuture + CancellationToken。TS 端因单线程事件循环天然无竞态;Java 端 activeRun 需保证跨线程可见性(volatile 或等效保证)。

入口的形态也值得看一眼。Agent#prompt 有四个重载:字符串、字符串加图片、单条消息、消息列表------全部收拢到 promptMessages 一个私有入口。它返回 CompletableFuture:调用方可以 await 它等运行结束,也可以拿着不管(fire-and-forget,靠事件流观察进度)------同步等待和异步观察两种姿势都开着。

还有一个容易被忽略的防御动作:启动循环前,createContextSnapshot 把 state.messages 拷贝一份快照再传入。循环期间外部若直接改 state.messages(比如手动 push 一条),不会污染正在运行的循环------本轮上下文在启动瞬间已冻结。运行中想加消息?走队列,不走后门。

并发模型 :TS 端单线程事件循环天然串行------循环、SSE 读取、事件处理全部在同一个执行上下文里;Java 端用虚拟线程模拟同样语义------约定所有状态更新发生在驱动线程上,工具可并行但结果回到驱动线程才入列。于是 AgentState 的字段全部不需要同步------没有并发写,就没有竞态。跨线程交互只经 volatile 标志。不是加了锁所以安全,是根本没有并发。

状态怎么更新?第四章讲过的铁律在这里有了新意义:Agent#processEvents 是唯一的状态更新入口------state 的运行时字段(streamingMessage、pendingToolCalls、errorMessage)不靠循环直接改,全部由事件驱动更新。循环只发事件,状态是事件的投影。好处是状态和事件永远一致------不存在「事件发了但状态没跟上」的窗口。

6.3 steering:运行中打断

steering 队列解决「进不去」:运行中调 Agent#steer(message),消息进队列,循环在下一个安全点把它捞进上下文。

安全点在哪?turn_end 之后、下一轮 LLM 调用之前------工具刚执行完、模型还没被再次调用,此刻注入消息,模型下一轮同时看到工具结果和你的新指令:「先做完这步,然后改方向」。时机是精确的:早一轮,打断正在执行的工具;晚一轮,模型已经基于旧方向输出了。

放行节奏由 QueueMode 决定,两种:

  • ALL:drain 时一次全放------攒了三条转向消息,下一轮模型一口气全看到。
  • ONE_AT_A_TIME:一次只放第一条------三条消息分三轮消化,每轮模型先回应上一条,再看下一条。消息之间有依赖、需要模型逐条消化时用它。

举个具体例子:用户在 UI 上连发三条补充------「改用英文」「加个图表」「控制在 500 字」。ALL 模式下一轮全给,模型可能只顾最新的 500 字忘了英文要求;ONE_AT_A_TIME 逐轮消化,每条都得到回应。方向变了、旧转向消息作废时,clearSteeringQueue 一键清空。两个检查点的位置关系,连着 followUp 一起看:

flowchart TD N[turn_end] --> Q1{&#34;shouldStopAfterTurn?&#34;} Q1 -->|继续| ST{&#34;steering 检查点<br/>(运行中插队)&#34;} ST -->|非空| I1[&#34;注入 → 下一轮&#34;] ST -->|空| N2[下一轮] Q1 -->|停| EXIT[内层循环退出] EXIT --> FU{&#34;followUp 检查点<br/>(本该停下时追加)&#34;} FU -->|非空| I2[&#34;注入 → 续跑一轮&#34;] FU -->|空| AE[agent_end]

6.4 followUp:排队追加

followUp 队列长得跟 steering 一样,语义完全不同。上图已经把两个检查点的位置拉开了,看队列检查的完整顺序:

text 复制代码
内层循环每轮末尾:
    若 shouldStopAfterTurn 说停: agent_end,结束
    若 steering 队列非空: 注入,继续下一轮        # 运行中插队
    若取消信号已触发: agent_end,结束

内层循环自然退出后(模型不再调工具):
    若 followUp 队列非空: 注入,续跑一轮          # 本该停下时的追加
    否则: agent_end,结束

steering 在「还要继续」时插队,followUp 在「本该结束」时续命。 打个比方:steering 是你凑到正在干活的同事耳边说「做完这步记得顺便看下 config.yaml」;followUp 是同事收拾包要下班了,你递过去一个新需求「走之前把这个也处理了」。前者改变当前任务的方向,后者追加一个新任务------注入时机不同,语义天差地别。

这个位置关系还顺带定死了优先级:只要还有工具调用或 steering 消息,内层循环就不会退出,followUp 检查点根本轮不到------打断优先于追加,是结构给定的,不是代码里写的 if-else。

followUp 的典型用法是链式任务:第一批消息跑完、模型不再调工具、循环准备收工,followUp 队列里还排着下一批------注入、续跑、再收工、再检查,直到队列空。批处理流水线、多步工作流,都是这个模式。队列运维也有配套接口:hasQueuedMessages 查两条队列,clearAllQueues 一键清空。

一个不起眼的细节:Agent#continueRun(TS 侧方法名为 continue()------Java 里它是关键字,故改名)在最后一条消息为 assistant 且有排队 steering 消息时,首次 steering 轮询被跳过(skipInitialSteeringPoll)------steering 消息已在 continue() 中手动取出并作为 prompt 传入,重复轮询会导致同一条消息被注入两次。若最后一条是 user 或 toolResult,走 runContinuation 路径,不涉及此标志。

6.5 abort 与 CancellationToken

「停不下来」的解法是 CancellationToken:一个 volatile 布尔标志加一个监听器回调------TS AbortSignal 的 Java 等价物,语义对齐、实现极简。Agent#abort 只做一件事:拿到 activeRun 里的令牌,cancel 它。

取消是协作式的,不是强杀。 cancel 只是立起标志,真正生效要等循环跑到下一个检查点:

flowchart TD C[&#34;abort() → token.cancel()<br/>volatile 标志立起&#34;] -.-> P1 C -.-> P2 C -.-> P3 C -.-> P4 subgraph K[&#34;协作检查点(Java 移植版)&#34;] P1[&#34;SSE 读取循环<br/>(TS 靠 AbortSignal 传到底层 HTTP)&#34;] P2[&#34;prepareToolCall<br/>(每步检查后)&#34;] P3[&#34;工具批次内<br/>(执行/预检逐个后)&#34;] P4[&#34;每轮末尾<br/>(TS 通过 stopReason 间接实现)&#34;] end P1 --> A[&#34;断开连接 / 跳过执行<br/>→ agent_end&#34;] P2 --> A P3 --> A P4 --> A

为什么协作式?工具是有副作用的------写文件、发请求、改数据库。强杀线程,副作用做到一半,状态撕裂;协作式等工具做完这一步、在安全点退出,历史里留下的是一致的状态。代价是延迟:最坏要等当前工具和当前流跑完。宁可慢一步退出,不留撕裂的状态。

取消(或任何失败)之后,Agent#handleRunFailure 会补发一组事件:MessageStart、MessageEnd、TurnEnd、AgentEnd------把带 ABORTED/ERROR 标记的 AssistantMessage 完整走一遍生命周期。外部订阅者看到的是一次完整的、有始有终的运行,而不是一条断掉的流。「失败也要有完整的事件序列」,这是给订阅者的契约。

最后是「完成」的定义。Agent#waitForIdle 返回的 promise,要等到循环结束、全部事件处理完、包括 agent_end 的所有订阅者结算完才 resolve------不是「模型说完最后一个字」,是「所有该收尾的都收尾了」。第四章那句反直觉的话在这里兑现:agent_end 发出,运行还没结束;waitForIdle 返回,才算真的结束。


至此,循环有了把手:能注入(steering/followUp)、能打断(abort)、能观察(state + 事件)、能等待(waitForIdle)。但这些把手都是写死在外壳里的------想加一个自定义的检查点、想改上下文的修剪策略,还是得改 Agent 或 AgentLoop 的代码。下一章是最后一个问题:不改循环的代码,怎么改变循环的行为。

第七章 问题⑥:不改循环的代码,怎么改变循环的行为?

架构定位 :core.types 的扩展契约(AgentLoopConfig 九钩子 + AgentMessage 开放接口)------循环的行为定制面。 本章设计思想:框架给原语,产品做决策------核心不内置任何「功能」,只暴露事件流(观察)、钩子(干预)、开放消息接口(扩展数据);密封用在稳定协议,开放在扩展点。

第六章末留的问题:外壳的把手够用了,但想把「预算花完就停」「上下文太长先压缩」「工具结果先脱敏再入历史」这些策略装进去,还是得改 Agent 或 AgentLoop 的代码。2.3 的缺口⑤(不可塑)是最后一个要回应的------答案不是把功能都做进去,而是要留出扩展接口,允许装任意功能。

7.1 原语而非功能

先看 pi-agent 核心没有内置什么:MCP 协议、权限弹窗、子 agent 编排、token 预算管理、历史压缩------这些「框架该有的功能」,一个都没有。不是没来得及,是刻意不给。

理由是这些「功能」全是决策 :MCP 要不要接、权限弹给谁看、子 agent 怎么调度、预算超了先压缩还是先停------答案因产品而异。框架替你选,必然选错一部分人。pi-agent 的选择是只给原语:事件流(观察)、九个钩子(干预)、开放消息接口(扩展数据)。决策留给上层。

小核心换三样东西:

  • 可理解:全部行为在几千行里读完,没有藏在抽象层后的魔法。
  • 可组合:钩子之间正交------脱敏钩子 + 预算钩子 + 压缩钩子互不干扰,各挂各的点。
  • 可测试:循环是无状态函数,钩子是纯接口,单测不需要起整个框架。

一句话:框架给原语,产品做决策。

原语能拼出「功能」吗?拿权限弹窗举例:beforeToolCall 拦截调用 + 事件流通知 UI + followUp 注入用户决策------三原语组合,弹窗就有了,而且交互细节完全由产品定义。子 agent 编排同理:事件流观察子 agent 进度 + steering 注入中间结果。功能不是不存在,是换了一种存在方式------从「框架内置」变成「原语组合」。

7.2 九个钩子的全景

AgentLoopConfig 用 9 个函数式接口把循环的关键决策点全部开放。按作用分六类:

flowchart LR subgraph I[&#34;改输入&#34;] TC[&#34;transformContext<br/>修剪历史&#34;] CL[&#34;convertToLlm<br/>翻译消息&#34;] end subgraph E[&#34;换装备&#34;] PN[&#34;prepareNextTurn<br/>context / model /<br/>thinkingLevel 整体替换&#34;] end subgraph X[&#34;拦执行&#34;] BT[&#34;beforeToolCall<br/>拦截调用&#34;] AT[&#34;afterToolCall<br/>改写结果&#34;] end subgraph S[&#34;控终止&#34;] SS[&#34;shouldStopAfterTurn<br/>预算 / 轮次 / 审批&#34;] end subgraph M[&#34;注消息&#34;] GS[&#34;getSteeringMessages&#34;] GF[&#34;getFollowUpMessages&#34;] end subgraph K[&#34;动态密钥&#34;] GA[&#34;getApiKey<br/>OAuth 令牌刷新&#34;] end

其中一半在前几章已经露过面,这里收拢成全景:

  • 改输入 :transformContext 在每轮调用模型前修剪历史------哪些消息该留、哪些该折叠,纯应用决策;convertToLlm 把修剪后的应用消息翻译成 LLM 协议消息(7.3 展开)。
  • 换装备 :prepareNextTurn 是九个里权限最大的------下一轮用哪个 context、哪个 model、什么思考级别,整体可换。历史压缩的接入点就在这:压缩器在钩子里跑,返回新 context,循环从下一轮开始用新历史------当前轮不受影响(prepareNextTurn 在 turn_end 之后调用,当前轮的 assistant 消息和工具结果已经确定,它只影响下一轮 LLM 调用)。
  • 拦执行 :beforeToolCall / afterToolCall------第五章的两道闸门,权限拦截与结果脱敏。
  • 控终止 :shouldStopAfterTurn------第四章四条终止路径里的「优雅停止」:预算花完、轮次到顶、人工审批不通过,钩子说了算。钩子拿到的是完整决策材料:当前消息、工具结果、上下文、新消息列表------不是只给个轮次号让你猜。
  • 注入消息 :getSteeringMessages / getFollowUpMessages------第六章两条队列的拉取口。注意钩子在这里只是「问你要消息」,队列本体在外壳里------循环不关心消息从哪来。
  • 动态密钥 :getApiKey 看着不起眼,解决的是 OAuth 令牌过期------每轮取密钥时钩子可以先刷新再返回,比静态配置多一次挽回机会。

挂载方式也简单:钩子全部经 AgentOptions 构造时传入,生命周期与 Agent 实例一致------没有运行时动态注册,也就没有钩子中途换掉的并发问题。九个钩子的共同形态:函数式接口、返回 CompletableFuture------异步友好,钩子内部可以再调远程服务(审批系统、密钥服务)而不阻塞循环。钩子之外还有一项同级注入点:streamFn------循环每次 LLM 调用都经它发起,默认实现从全局注册器解析(setDefaultStreamFn / getDefaultStreamFn),循环只认函数、不认实现;换 Provider、换协议、测试注入假流,都不必动循环本体------与钩子同一姿态,把「谁来实现」外置给配置。

7.3 上下文工程与自定义消息

九个钩子里最值得单独展开的是改输入那一对------它们合起来构成一条翻译流水线:

flowchart LR H[&#34;AgentMessage[]<br/>应用历史(可含自定义消息)&#34;] -->|&#34;transformContext<br/>(可选:修剪)&#34;| T[&#34;AgentMessage[]<br/>修剪后&#34;] T -->|&#34;convertToLlm<br/>(翻译)&#34;| M[&#34;Message[]<br/>LLM 协议消息&#34;] M --> L[LLM]

为什么中间要隔一层 AgentMessage,不直接用 LLM 的 Message?因为应用历史和协议消息是两个世界:应用历史里可以有 UI 通知、执行记录、自定义角色消息------这些对 LLM 没有意义,直接发过去轻则浪费 token,重则模型困惑。翻译流水线给了两次机会:transformContext 决定「留多少」,convertToLlm 决定「怎么呈现」------通知消息可以过滤掉,也可以转成一行 system 摘要,纯应用决策。

支撑这一切的是 AgentMessage 的接口设计------全文唯一一个刻意开放的接口:

java 复制代码
public interface AgentMessage {
    String role();
    long timestamp();
    boolean isStandardMessage();
    default Message asStandardMessage() { /* 标准消息适配 */ }
    default AssistantMessage asAssistantMessage() { /* assistant 特化 */ }
}

public record StandardAgentMessage(Message message) implements AgentMessage {
    public String role() { return message.role(); }
    // ... 包装标准 Message,isStandardMessage() = true
}

标准消息(user / assistant / toolResult)经 StandardAgentMessage 适配进来;应用想加自定义消息,实现 AgentMessage 接口就能进历史。举个具体例子:一个 UINotificationMessage(role = "notice",记录一次界面弹窗)------它进 state.messages、随事件流广播、订阅者能在时间线上渲染「这里弹过一次通知」;而 convertToLlm 翻译时对它返回空或转成一行摘要------LLM 看不见它,或只看见一句话。循环、事件流、订阅者全都认它,只有模型的世界里它不存在。

asXxx 默认方法也有讲究:它们是类型安全的向下钻取------asAssistantMessage 返回非 null 即可确认是 assistant 消息并直接拿强类型(第六章 processEvents 提取 errorMessage 用的就是它),不用 instanceof 链。

回看全文的密封与开放,这里有一条清晰的架构决策线:密封用在稳定协议,开放用在扩展点。ai 层的 Message 是协议------user/assistant/toolResult 三类,模型 API 定死了,密封让 switch 穷尽、编译器把关;core 层的 AgentMessage 是扩展点------应用要塞什么消息进历史,框架不可能预知,开放让实现自由。假如反过来把 AgentMessage 也密封,自定义消息就得靠「塞进某个标准类型的 content 里」硬凑------7.3 的整个机制就塌了。(TS 侧用 declaration merging 达成同样的开放性,殊途同归。)


九个钩子、两层翻译、一个开放接口------不改一行循环代码,循环的行为已经被定制得面目全非。至此六个问题全部有了答案,也该收账了:这个小 Agent 造完了,和真正的产品级框架比,还缺什么?

第八章 收束:小的造完了,大的缺什么

本章设计思想:核心定义契约,harness 提供默认履约------核心解决「一次运行怎么做对」,harness 解决「一个产品怎么用好」;进程重启不丢状态是一等设计目标。

六个问题问完,答案可以收进一张表:

问题 机制 关键方法
② 输出怎么消费 SSE → 12 种事件 → EventStream OpenAiCompletionsApi#doStream / EventStream#push
③ 会话内部发生什么 双层循环 + 两层事件 AgentLoop#runAgentLoop / Agent#processEvents
④ 工具怎么执行 四步预检 + 并行编排 + 保守终止 prepareToolCall / shouldTerminateToolBatch
⑤ 怎么打断、加活 两条队列 + 协作式取消 Agent#steer / Agent#followUp / Agent#abort / Agent#waitForIdle
⑥ 怎么不改代码定制 九钩子 + 开放消息接口 AgentLoopConfig / AgentMessage

「一个小的 agent」的全貌不过如此:AgentLoop(双层循环 + 工具编排)+ Agent(有状态外壳)+ 一层类型定义------没有任何魔法。你在 Codex 里看到的那场搜索、改码、跑测试的表演,底下就是这张表。

核心之外还有一层没讲的:Harness(外壳)。核心解决「一次运行怎么做对」,harness 解决「一个产品怎么持续运行」------harness 不仅做产品化,也为核心的必选钩子(如 convertToLlm)提供了默认实现:核心定义契约,harness 提供默认履约。后续按模块逐项点名,每项一句话:

  • 会话持久化 (session):树形 JSONL 存储,重启后上下文重建------进程中断或重启不丢状态是一等设计目标;会话是树、操作是可挂起的状态机,保证随时能断点续跑。
  • 消息管理(messages):自定义消息的注册与序列化。
  • 执行环境(ExecutionEnv):文件系统、Shell 执行与运行环境的统一管理。
  • 内置工具集(tools):read / write / edit / bash / image 等 UI 无关的通用工具------统一契约、图像处理器可插拔,场景层(如编码终端)在其上做封装。
  • 上下文压缩(compaction):历史太长时的折叠与摘要。
  • 系统提示词组装(system-prompt):多来源拼装 system prompt。
  • 提示词模板(prompt-templates):参数化的提示词复用。
  • 技能(skills):可安装的能力包。
  • 通用钩子(hooks):harness 层生命周期钩子------区别于第七章的循环钩子。
  • AgentHarness 总装:生命周期管理、操作锁定、Turn 安全点、settlement 结算。

至此可以回答标题的后半句了:剥开 Agent 框架,你看到的不是黑盒,是一组可以亲手造出来的零件------协议层把字节变成事件,循环(Loop)把事件变成会话,外壳(Agent)把会话变成可控的运行时,钩子(Hooks)把运行时变成你的。剩下的,是产品化的脏活------而脏活,从来不是框架的专利。

相关推荐
方方洛1 小时前
ai-agent教程-04-记忆管理
人工智能·llm·agent
sp421 小时前
Java 加解密组件再设计
java·后端
鶴哥只手遮天1 小时前
从零搭建光电仿真引擎(二):场景建模与材质系统
后端
柠檬味拥抱1 小时前
IP102农作物害虫检测数据集 | 4400张YOLO智慧农业数据集
后端
我是你的开心果7781 小时前
ai全栈软件开发学习day27
人工智能·学习·状态模式
龙腾AI白云1 小时前
【轻量化大模型:低成本AI落地的产业新范式】
大数据·数据库·人工智能·机器学习
llqbzllll1 小时前
什么是零拷贝?别被“零”字骗了:一次讲透完整链路
后端
llqbzllll1 小时前
为什么 NoSQL 查询更快?答案不在数据库名字里
后端
W***25921 小时前
2026企业AI办公工具选型全指南
大数据·人工智能