跳到主要内容

总览:笼子而非动物

30 秒导读: Yao 是一个单二进制的 AI 应用引擎,agent/ 是它内部的 agent 子系统。它自己不训练、不托管模型(那是"动物"),而是给外部模型套一个可控的笼子:一条 Stream() 主管道,把权限校验、历史拼装、沙箱、Create/Next 钩子、LLM 流式调用、工具循环、多智能体委派串成一次可观测、可拦截、可委派的对话。你写的不是模型,是笼子的形状。

本章只讲全景与导航——这是什么、大盘怎么转、各部件干什么、六章怎么读。任一机制的实现细节都留给后续章节,本章不深入。


1. 这是什么(零基础也能懂)

一句话定义

Yao agent 是单二进制 AI 运行时里的 agent 子系统:用几个配置文件(DSL)描述一个"助手",引擎把它装载成一个能对话、能调工具、能委派给别的助手的可运行对象。

"笼子而非动物"的心智模型

这是理解整个子系统最重要的一句话。

  • 动物 = 模型本身。它的智能、它会说什么,来自外部 LLM(通过 connector 接入,如 gpt-4o)。Yao 不生产这份智能。
  • 笼子 = 这套 agent 运行时。它决定动物能看到什么(拼进 prompt 的历史与检索)、能碰什么(暴露给它的 MCP 工具)、说的话如何落地(工具执行、沙箱隔离)、什么时候该换一只动物(委派给子 agent)。

所以你作为开发者,写的从来不是"更聪明的模型",而是笼子的形状:喂什么上下文、开哪些工具、在模型开口前后插什么钩子、越界了怎么拦。模型是可替换的租客,笼子是你的资产。

三种执行器模式(一个 agent 到底"跑"什么)

同一条 Stream() 主管道,按 agent 配了什么,分岔成三种执行形态。判断依据都在 agent/assistant/agent.goStream 里:

模式触发条件(配了什么)笼子里关的是谁典型用途
LLM 模式配了 PromptsMCP一个云端聊天模型,逐 token 流式吐字、按需调 MCP 工具常规对话助手、带工具的 RAG
CLI-Agent 沙箱模式配了 Sandbox V2(HasSandboxV2())一个跑在隔离盒子里的命令行编码 agent(claude / opencode / yaocode / tai),工具由它自己内部消化让 AI 在沙箱里读写代码、跑命令
纯 Hook 模式只配了 HookScript,没有 Prompts/MCP/沙箱没有模型,只有你写的 JS 逻辑纯路由/编排 agent:不问模型,直接按规则委派

三者的共同点是同一条主管道、同一套 Create/Next 钩子、同一套流式输出——这正是"钩子统一三模式"的巧妙(见 §5)。

依据:LLM 块 gated on ast.Prompts != nil || ast.MCP != nil(agent/assistant/agent.go:283);沙箱分支 ast.HasSandboxV2()(agent/assistant/agent.go:317);纯 Hook 靠 ast.HookScript != nil 而其余为空。

给谁用

  • 应用开发者:想在自己的产品里塞一个会用工具、会查库、能编排的 AI 助手,但不想从零搭运行时。
  • 不想被单一模型锁死的人:connector 一换就换模型,笼子不动。
  • 要做多智能体协作的人:一个主 agent 委派给若干专家 agent,引擎负责隔离与汇聚。

用起来什么样(最小示例)

一个 agent 就是一个目录,最少三个文件(引自 agent/README.md):

assistants/
└── my-assistant/
├── package.yao # 配置:名字、用哪个 connector(模型)、开哪些能力
├── prompts.yml # 系统提示词
└── src/index.ts # (可选)Create/Next 钩子,给笼子加机关

package.yao —— 指定这只笼子关哪只动物(agent/README.md):

{
"name": "{{ name }}",
"connector": "gpt-4o",
"description": "{{ description }}"
}

prompts.yml —— 系统提示词:

- role: system
content: |
You are a helpful assistant.

src/index.ts —— 可选钩子,在模型开口前拦一道,把"退款"类请求直接甩给专家 agent(引自 agent/README.md,# 示意):

import { agent } from "@yao/runtime";

// Create 钩子:LLM 调用前运行,可改消息、可直接委派
function Create(ctx: agent.Context, messages: agent.Message[]): agent.Create {
const last = messages[messages.length - 1]?.content || "";
if (last.includes("refund")) {
// 不问模型,直接把这轮交给退款专家 agent
return { delegate: { agent_id: "refund-specialist", messages } };
}
return null; // 返回 null = 什么都不改,照常走 LLM
}

跑起来后,对外是一个 OpenAI 兼容端点(agent/README.md):POST /v1/chat/completions

一句话直觉:你写的三个文件就是笼子的三面墙——一面选动物(connector)、一面定它听到的话(prompts)、一面装拦截机关(hooks)。


2. 顶层全景(一次请求怎么走完)

整个子系统的价值主线是一次请求的一生——Stream() 方法(agent/assistant/agent.go:21,func (ast *Assistant) Stream)。下面这张图从上到下就是它的执行顺序;虚线是"工具循环"的回边。

怎么读这张图: 从上往下是主流程,每个方框旁标了它所在的文件;右边虚线箭头是"模型调了工具、把结果喂回去再问一轮"的循环。

POST /v1/chat/completions


┌──────────────────────────────────────────────┐
│ Stream() agent/assistant/agent.go:21 │ ← 主管道入口
└──────────────────────────────────────────────┘

① 权限校验 checkPermissions assistant/permission.go

② 进栈 + 缓冲 EnterStack context/stack.go:200
InitBuffer context/buffer.go

③ 拼历史 WithHistory assistant/history.go

④ 起沙箱(可选)initSandboxV2 assistant/sandbox_v2.go

⑤ Create 钩子 ───────────────► 若 delegate,直接转子 agent(跳过 LLM)
hook/create.go:13 │
│ └─── handleDelegation → 子 agent 的 Stream()

⑥ 建请求 BuildRequest + 自动检索 shouldAutoSearch
assistant/llm.go / search.go

⑦ ┌── LLM 流 executeLLMStream ──────┐ assistant/llm.go
└── 或 沙箱流 executeSandboxV2Stream │◄──┐ assistant/sandbox_v2.go
│ │
⑧ 工具调用 executeToolCalls(带重试×3) │ assistant/mcp.go:223
│ │
⑨ ┌ 有 Next 钩子 → processNextResponse │ 循环回边
├ 无钩子+有工具结果 → executeToolLoop ┘ assistant/loop.go:32
│ (max_turn 到顶 → loop_fallback 委派)
└ 其余 → buildStandardResponse

⑩ Next 钩子 hook/next.go:13(可再改、可再委派)


⑪ 流式响应 + [DONE](仅 root 栈关闭输出)
context/output.go

主线走一遍(高层,不进代码):

  1. 请求进来,先校验权限,再进栈(为多智能体建立调用树)并开一个缓冲区(整轮的消息/步骤攒着,退出时一次落库)。
  2. 拼历史:把这轮输入和数据库里的会话历史合成 fullMessages
  3. 若配了沙箱,先起沙箱(必须在钩子之前,好让钩子能访问沙箱上下文)。
  4. Create 钩子先跑——它可以改消息、改选项,甚至当场委派给别的 agent(那就跳过后面的 LLM 直接返回)。
  5. LLM 流(或沙箱流),边生成边流式吐给前端;要工具就产生 tool_calls。
  6. 执行工具(带最多 3 次纠错重试),结果或交给 Next 钩子,或进工具循环再问模型几轮。
  7. Next 钩子做最后加工/再委派,root 栈关闭输出[DONE]

3. 部件一句话职责表

agent/ 下每个子包干一件事。下面是选章时的"部件地图":

部件一句话职责在哪(agent/ 下)
assistant笼子本体:装载 DSL、跑 Stream() 主管道、钩子/LLM/工具/循环/委派全在这assistant/(核心 agent.go)
context一次请求的随身上下文:调用栈、缓冲、输出、以及注入给 JS 钩子的 JSAPI(V8 桥)context/
llm模型接入层:解析 connector、探测能力(vision 等)、把消息喂给底层 LLM 库llm/
memory四层记忆(user/team/chat/context),给 agent 跨请求/跨会话的 KV 记忆memory/
sandboxCLI-Agent 执行器:在隔离盒子里跑 claude/opencode/yaocode/tai 等命令行 agentsandbox/v2/
mcp把 MCP(Model Context Protocol)工具收集、暴露给模型、并执行模型点名的工具assistant/mcp.go
store会话/消息/助手模型的持久化(xun/mongo/redis 多后端)store/
output流式输出适配:把内部事件转成 SSE/OpenAI 兼容 chunk 发给前端output/
searchWeb / 知识库 / 数据库三路检索,喂给自动 RAGsearch/

4. 阅读地图(六章建议顺序)

按"由浅入深、先主干后分支"排,建议顺序如下。每章一句话导读(本页即索引,不重复列自身):

  1. 01-loading.md — 装载:从 DSL 到可运行的 Assistant package.yao + prompts.yml + index.ts 三个文件,是怎么被 LoadPath/LoadStore 读成一个内存里的 Assistant 对象的。先懂"笼子怎么建"。

  2. 02-pipeline.md — 主管道:一次请求的一生(Stream) 逐段拆 Stream():权限→栈→缓冲→历史→沙箱→Create→LLM→工具→Next→输出。这是全系统的主干,建议重点读。

  3. 03-hooks-jsapi.md — Hooks 与 Context JSAPI:V8 桥与边界注入 Create/Next 钩子怎么在 V8 里跑你的 TS,ctx.Send/ctx.agent/ctx.memory/ctx.mcp 这些 JSAPI 怎么把 Go 能力注入到 JS 边界。

  4. 04-toolloop-mcp.md — 工具循环与 MCP:把模型的话落到真实工具 MCP 工具怎么收集与执行、工具调用的 3 次纠错重试、以及 max_turn 兜底的多轮工具循环。

  5. 05-multiagent-stack.md — 多智能体编排:Stack、委派与 A2A 调用栈(Stack)怎么建调用树,delegate 委派与 ctx.agent.Call/All/Any/Race 的 A2A 并发怎么隔离历史。

  6. 06-memory-sandbox.md — 记忆与沙箱:四层记忆与 CLI-Agent 执行器 四层记忆(user/team/chat/context)的作用域与生命周期,以及 Sandbox V2 怎么在隔离盒子里跑命令行编码 agent。


5. 巧妙之处速览

后面各章会展开,这里先给"要带走的四个精华",建立预期:

  1. 钩子统一三模式。 不管是 LLM 模式、CLI-Agent 沙箱模式还是纯 Hook 模式,Create/Next 两个钩子的位置与语义完全一样——都夹在主管道同一处。于是"改上下文、拦截、委派"的心智模型只需学一遍,三种执行器通用。 依据:Create 在 LLM/沙箱分岔之前(agent/assistant/agent.go:217),Next之后(agent/assistant/agent.go:501)。

  2. 工具循环 max_turn + loop_fallback 双重兜底。Next 钩子但有工具结果时,进多轮工具循环(默认 5 轮,读 mcp.options.max_turn);轮数耗尽或循环报错,不是硬崩,而是委派给内置 __yao.loop_fallback agent兜底收尾。 依据:getMaxToolLoopTurns 默认 5(agent/assistant/loop.go:178);buildLoopFallbackDelegate 指向 __yao.loop_fallback(agent/assistant/loop.go:204)。

  3. 四层记忆按作用域分层。 user(跨该用户所有会话)、team(团队共享)、chat(单会话内)、context(单请求临时)——同一套 KV 接口,四种生命周期,按需选层。 依据:SpaceUser/SpaceTeam/SpaceChat/SpaceContext(agent/memory/types.go:15-27)。

  4. A2A fork 隔离历史。 主线委派(delegate)算正常对话流、会存历史;但 ctx.agent.Call/All/Any/Race 这类并发 fork 调用会被识别为 forked A2A,自动跳过历史落库,避免子 agent 的中间对话污染主会话。 依据:IsForkedA2ACall() 触发 opts.ForceA2A()(agent/assistant/agent.go:70;agent/context/context.go:543)。


6. 顶层代码地图(导航索引)

三列表:主题 → 文件 → 真实符号名。行号会随上游漂移,优先用符号名 grep 定位。所有引用 as-of sourceCommit

主题文件(agent/ 下)关键符号
主管道入口(一次请求的一生)assistant/agent.go:21Stream
connector/能力解析assistant/agent.go:645GetConnectorinitializeCapabilities
装载 DSL → Assistantassistant/load.go:317:222LoadPathLoadStore
Assistant 内存结构assistant/types.go:29AssistantHookScript
Create 钩子assistant/hook/create.go:13(*Script).Create
Next 钩子assistant/hook/next.go:13(*Script).Next
早退委派(Create 里 delegate)assistant/next.go:43(调用点 assistant/agent.go:251)handleDelegation
MCP 工具收集/执行assistant/mcp.go:75:223buildMCPToolsexecuteToolCalls
工具循环 + 轮数/兜底assistant/loop.go:32:178:204executeToolLoopgetMaxToolLoopTurnsbuildLoopFallbackDelegate
调用栈(多智能体树)context/stack.go:200:115EnterStack(*Stack).IsRoot
A2A fork 判定context/context.go:543(*Context).IsForkedA2ACall
A2A 并发调用(JSAPI)context/jsapi_agent.go:83agentCallMethodagentAllMethodagentAnyMethodagentRaceMethod
JSAPI 注入(V8 桥)context/jsapi.go:49:66:72:151SendnewMCPObjectnewAgentObjectmemory
四层记忆作用域memory/types.go:15SpaceUserSpaceTeamSpaceChatSpaceContext
CLI-Agent 执行器sandbox/v2/runners.go:13SupportedRunners(yaocode/tai/claude/opencode)
沙箱初始化(主管道内)assistant/agent.go:167HasSandboxV2initSandboxV2
会话缓冲(整轮攒着再落库)assistant/chat.go:98:195;缓冲类型 context/buffer.go(*Assistant).InitBufferFlushBufferChatBuffer