Eino 架构与 Agent Engine 开发课

Eino 架构与 Agent Engine 开发课

面向:几乎没写过 Go,但需要给 Code Agent 下任务、做架构选择和代码验收的 Agent Engine 负责人
版本基线:Eino v0.9.15,Go 1.21+
目标:建立 Eino 整体知识,能够设计、开发和评审独立的 Go/Eino Agent Engine

读法

第一次按顺序读第 0~7 章,先建立框架地图。第 8~16 章讲运行治理,适合带着具体设计问题读。附录提供 Code Agent 任务合同、架构评审清单和术语表。

每章最后有一道“闭卷检索题”。先不翻答案,写两三句话。能从记忆中说出边界,比看懂代码更重要。

本课程中的代码分两种:标为“骨架”的片段表达接口与依赖关系;标为“可编译方向”的片段接近真实 API,但仍需要 Code Agent 按锁定版本和具体 Provider 补全配置。不要复制一段代码就直接上线。

目录


第 0 章:先画出 Eino 地图

0.1 Eino 是什么

Eino 是 Go 语言的 LLM 应用开发框架。它提供四层能力:

主要包 你可以把它理解成 典型对象
数据合同 schema 框架里的共同语言 Message、AgenticMessage、ToolInfo、Document
原子能力 components 可替换的零件接口 Model、Tool、Retriever、Embedding、Indexer
编排 compose 有类型的执行图 Runnable、Chain、Graph、Workflow、Checkpoint
Agent Runtime adk Agent 循环和运行控制 ChatModelAgent、Runner、AgentEvent、DeepAgent

这四层的依赖方向应当从上往下:ADK 使用 Compose 和 Component,Component 使用 Schema。业务层可以调用它们,但 Eino 不知道你的 tenant_id、聊天表、审批规则或业务 artifact。

flowchart TB
    Product[产品协议:HTTP / SSE / A2UI] --> App[业务应用层:Run / Session / Policy / Artifact]
    App --> ADK[Eino ADK:Agent / Runner / TurnLoop]
    ADK --> Compose[Eino Compose:Chain / Graph / Workflow]
    ADK --> Components[Eino Components:Model / Tool / Retriever]
    Compose --> Components
    Components --> Schema[Eino Schema]
    App --> Infra[DB / Queue / Object Store / Workspace Executor]

0.2 Eino 不是什么

Eino 不是完整的 Agent 平台。以下责任仍在应用层:

  • 对外 API、认证、租户隔离和限流
  • run_idsession_id、消息持久化和业务状态机
  • Tool 的授权、审计、幂等和副作用边界
  • 模型与工具预算、计费、SLO 和告警
  • SSE 事件合同、A2UI 映射和前端兼容
  • artifact、evidence、评测集和回放语义
  • Workspace Executor 或其他工作区执行器的生命周期

一句判断法:Eino 管“这次 Agent 怎么跑”;应用管“为什么允许跑、跑的是谁的任务、跑完留下什么可追责事实”。

0.3 三组容易混淆的仓库与 Skill

名称 用途
cloudwego/eino 公共核心框架
cloudwego/eino-ext 公共 Provider/存储等组件实现
cloudwego/eino-examples 示例,不是生产模板

Code Agent 使用的 eino-guideeino-componenteino-composeeino-agent 是写代码时的知识插件。ADK 的 Skill Middleware 是 Agent 运行时按需加载指令和资源的能力。两者同名为 Skill,运行位置完全不同。

0.4 版本纪律

课程按 v0.9.15。如果项目锁在 v0.9.14,先读 release diff,再决定是否升级。不要让 Code Agent默认取 main:Eino 的 ADK 仍在快速变化,main 上的接口和稳定 tag 可能不一致。

v0.9.15 相比 v0.9.14 的核心变化很小,主要修正 ADK filesystem 的越界读取处理。但新服务仍应精确 pin 版本,因为“差异很小”不是允许浮动依赖的理由。

0.5 给 Code Agent 的任务合同

text
mode: investigate
repo: <新 Go 服务仓库>
branch: <当前分支,只读>
scope: Eino 依赖与架构入口
allowed: 读取 go.mod、源码、Eino v0.9.15 release/source
forbidden: 修改文件、升级依赖、访问真实凭据、调用生产模型
output:
  1. 当前 Eino/eino-ext 精确版本
  2. schema/components/compose/adk 在本项目中的入口文件
  3. 应用层自有的 Run/Session/Policy/Event/Artifact 模块
  4. 过时接口或版本漂移风险,附源码证据

0.6 评审清单

  • go.mod 是否精确锁定 Eino 与扩展版本
  • Provider、存储与回调能力是否来自受维护的扩展,而非复制一份私有实现
  • Eino 对象有没有直接依赖 HTTP DTO、数据库实体或前端事件
  • 应用层是否明确拥有 Run、Session、Policy、Event 和 Artifact
  • 示例代码是否经过锁定版本的编译测试

0.7 闭卷检索题

如果模型成功返回文本,但 SSE 连接断了,应该由 Eino ADK 还是业务应用层决定重连和事件补发?为什么?

0.8 一手资料


第 1 章:Schema、消息与模型

1.1 Schema 是运行合同,不是普通结构体集合

模型、工具、图节点和回调需要共享数据语义。schema 提供这些类型。先掌握四个:

  • schema.Message:经典聊天消息,角色包括 system、user、assistant、tool
  • schema.AgenticMessage:v0.9 的 Agentic 消息,以 ContentBlock 表达文本、推理、工具调用和工具结果
  • schema.ToolInfo:模型看到的工具名称、描述与输入 Schema
  • schema.StreamReader[T]:单消费者的流式读取器

1.2 两条消息轨

经典轨:schema.Message

适合已有 Chat Completions Provider、Eino 内置经典 ReAct、需要明确在客户端执行 Tool 的场景。一个 assistant 消息可以带 Tool Calls,随后必须有对应 ToolCallID 的 tool 消息。

go
messages := []*schema.Message{
    schema.SystemMessage("You are a governed analysis agent."),
    schema.UserMessage("分析 campaign 123"),
}

reply, err := chatModel.Generate(ctx, messages)

Agentic 轨:schema.AgenticMessage

适合原生 Agentic Model。内容由 block 组成,可以保留 Provider 原生的工具、推理和多模态语义。

go
messages := []*schema.AgenticMessage{
    schema.SystemAgenticMessage("You are a governed analysis agent."),
    schema.UserAgenticMessage("分析 campaign 123"),
}

reply, err := agenticModel.Generate(ctx, messages)

model.AgenticModel 本质上是 BaseModel[*schema.AgenticMessage]。它只有 GenerateStream 两类核心调用。工具通常通过每次请求的 option 传递,不应靠修改共享模型实例来绑定。

1.3 Greenfield 怎么选

新服务优先评估 Agentic 轨,但不能仅凭“新”字决定。要问:

  1. 目标模型平台的 Agentic 接口是否已稳定?
  2. 需要的 Tool Calling、流式事件、取消和 retry 在该轨是否已接线?
  3. 产品是否要保留 Provider 原生 ContentBlock?
  4. 现有下游是否只接受经典 Message?

v0.9 源码明确指出:经典 Message 的 ADK 功能更完整;AgenticMessage 单 Agent 可用,但部分流式取消和 retry 能力仍有限。因此业务应用可以把 Agentic 设为目标合同,同时为已验证的经典 ReAct Adapter 保留兼容实现。不要在一个 run 中随意来回转换。

1.4 Message 不是聊天记录表

schema.Message 表达一次模型上下文中的消息。数据库消息还需要业务字段:tenant、session、run、版本、可见性、脱敏级别、创建者和证据引用。直接把 Eino Message JSON 当数据库模型,会让框架升级变成数据迁移。

建议使用显式投影:

text
DomainMessage --project--> ModelContextMessage
AgentEvent    --adapt----> ProductEvent

历史 run 通常只投影用户问题、最终回答、摘要和必要证据。不要把所有旧 Tool Call/Tool Result 原样塞回下一次模型上下文。当前 run 内的 Tool Call 与 Tool Result 则必须成对,不能删除一半。

1.5 流的三个规则

  • StreamReader 必须关闭,最好在拿到后立即 defer reader.Close()
  • 流通常只能消费一次;回调、日志和业务若都要读,需要在正确位置复制或做事件分发
  • 读到 io.EOF 是正常结束,其他错误才是失败

Eino 的 model stream 是 token/content block 流。产品 SSE 是带 event_id、run 状态、重连语义的外部协议。两者不能直接等同。

1.6 给 Code Agent 的任务合同

text
实现一个只做消息边界验证的 spike:
- 版本:github.com/cloudwego/eino v0.9.15
- 同时定义 DomainMessage 与到 schema.AgenticMessage 的单向投影
- 不把 Eino 类型写入数据库实体或 HTTP DTO
- 为 text、tool call、tool result、非法未配对结果写表驱动测试
- 流式示例必须关闭 reader,并正确区分 EOF 与错误
- 输出一份 classic Message 与 AgenticMessage 能力差异说明
不要接真实 Provider,不要引入重试。

1.7 评审清单

  • 是否明确选定消息轨,还是在代码中隐式混用
  • Tool Call ID 与 Tool Result 是否严格关联
  • 是否把 reasoning 或敏感 Provider 元数据直接写入日志
  • 是否关闭所有 stream;错误路径是否也能关闭
  • 业务存储是否通过投影隔离 Eino 类型
  • 是否把共享模型实例做了可变的 per-request Tool 绑定

1.8 闭卷检索题

为什么历史消息可以只保留“结论和证据投影”,而当前 run 的 Tool Call 与 Tool Result 不能只留一边?

1.9 一手资料


第 2 章:Agent、ChatModelAgent、Runner 与事件

2.1 从模型调用到 Agent 运行

ChatModel 只回答“一次调用”。Agent 需要决定是否调用工具、把工具结果交回模型、何时结束,以及如何发出过程事件。

经典 ReAct 的最小循环是:

flowchart LR
    I[输入消息] --> M[模型调用]
    M -->|普通回答| F[最终输出]
    M -->|Tool Calls| T[执行工具]
    T --> R[追加 Tool Results]
    R --> M

ChatModelAgent 提供这个循环。Runner 是标准运行入口,负责启动、恢复、checkpoint 接线和事件迭代。业务服务应调用 Runner,而不是到处直接调用 Agent 的 Run

2.2 经典 ChatModelAgent 骨架

go
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:          "governed_analysis",
    Description:   "Runs governed analysis tasks",
    Instruction:   systemPrompt,
    Model:         chatModel,
    ToolsConfig:   toolsConfig,
    MaxIterations: 8,
    Handlers:      handlers,
})
if err != nil {
    return err
}

runner := adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           agent,
    EnableStreaming: true,
    CheckPointStore: checkpointStore,
})

iter := runner.Query(ctx, userQuery)
for {
    event, ok := iter.Next()
    if !ok {
        break
    }
    if event.Err != nil {
        return event.Err
    }
    // 交给应用事件适配器,不要直接写 SSE
}

Agentic 轨使用泛型构造:NewTypedChatModelAgent[*schema.AgenticMessage]NewTypedRunner。它不是把类型名替换一下就结束;你必须核对目标能力在 Agentic 路线是否已实现。

2.3 AgentEvent 应该怎么看

事件可能包含:

  • Output:完整消息或消息流
  • Action:中断、退出等运行动作
  • Err:这次事件携带的错误
  • AgentName:事件来源
  • RunPath:嵌套路径;对 AgentTool/DeepAgent 通常很简单

不要假设每个事件都是最终回答,也不要通过自然语言猜终态。应用适配器应按结构化字段和自己的状态机映射。

流式输出还带一个所有权问题:如果事件中的 MessageStream 没被消费,也要关闭。框架建议对自定义 Agent 发出的流设置自动关闭;业务消费者仍应做到显式生命周期管理。

2.4 最大迭代数不是预算系统

MaxIterations 防止 Agent 无限循环,但它不等于:

  • 最大模型调用成本
  • 最大工具副作用次数
  • wall time
  • Provider retry 次数
  • 并发 run 配额

业务应用层应分别定义 max_model_turnsmax_tool_callswall_timeout、每工具 deadline、token/cost budget。然后把能下推的限制交给 Eino,其他限制由 Supervisor/Broker 执行。

2.5 给 Code Agent 的任务合同

text
实现一个 Fake Model 驱动的 ChatModelAgent 最小运行测试:
- 使用真实 Eino Agent loop,不自己写 for 循环模拟 ReAct
- 第一 turn 返回一个 tool call,第二 turn 返回最终文本
- Runner 统一启动,收集所有 AgentEvent
- 断言模型调用次数、工具调用次数、ToolCallID 配对和最终输出
- max iterations 设为显式小值
- 覆盖模型错误、工具错误、context cancel、流未消费的关闭
- 不接 HTTP、数据库、真实模型网关

2.6 评审清单

  • 是否误写了一套自己的 ReAct 循环
  • 是否所有运行都经 Runner,便于恢复和事件治理
  • 是否区分过程事件、输出、动作和错误
  • 是否用自然语言内容判断 answeredcancelled 等终态
  • 是否把 MaxIterations 当成全部预算
  • Fake Model 是否确定性验证真实 Agent loop

2.7 闭卷检索题

Runner 和 ChatModelAgent 的职责有什么区别?为什么 HTTP handler 不应直接遍历一堆模型与工具调用?

2.8 一手资料


第 3 章:对话历史、Memory、Session 与 Store

3.1 四个概念分开

概念 含义 默认所有者
Model context 某次模型调用实际收到的消息 Agent + 应用投影器
Conversation history 用户可见的多轮对话事实 应用数据库
Session values 一次会话或运行中的键值上下文 应用定义,ADK 可在运行中携带
Checkpoint 中断后恢复执行所需的框架状态 Eino Store + 应用保管策略

Memory、Session 和 Store 在业务语义上不是 Eino core 替你完成的产品能力。Eino 有 Session value、run-local value 和 CheckPointStore 接口,但“该存什么、保留多久、谁能读”仍由业务应用决定。

3.2 推荐的数据流

flowchart LR
    DB[(Domain messages)] --> P[Context projector]
    P --> C[Model context]
    C --> R[Eino Runner]
    R --> E[Agent events]
    E --> A[Event adapter]
    A --> DB
    R <--> CP[(Checkpoint store)]

Context projector 应处理:

  • 窗口截断与 token 预算
  • 历史摘要
  • evidence 引用
  • Tool Call/Result 成对校验
  • 不同角色和内容块的兼容
  • tenant 与敏感数据过滤

3.3 三种状态的生存期

Run-local state 只在一次执行及其 resume 中存在,适合本轮计数、临时选择和中间游标。需要 checkpoint 的自定义类型必须可序列化并注册。

Session state 跨多轮,例如用户偏好、选中的数据域、已确认的工作区。它必须有业务 Schema、版本和存储策略。

Business facts 是报告状态、审批结论、证据 hash 等真相。它们不能只存在 checkpoint。Checkpoint 是恢复 payload,不是业务事实源。

3.4 Prompt 长度不是用删消息解决的

简单删掉最老消息会破坏 Tool 配对,也可能删掉用户已确认的约束。生产策略通常是:

  1. 保留当前用户请求和本轮完整执行链。
  2. 保留系统约束和不可丢的业务确认。
  3. 把更早历史压成有版本的摘要。
  4. 证据保留引用和摘要,需要时通过工具再取。
  5. 达到硬预算时明确失败或要求新会话,不静默改变语义。

3.5 给 Code Agent 的任务合同

text
设计并实现 ContextProjector:
- 输入是 DomainConversation,不接收数据库 entity 指针
- 输出是选定消息轨的模型消息
- 当前 run 的 tool call/result 必须成对且顺序合法
- 历史 run 只保留用户输入、最终回答、摘要与 evidence refs
- 预算按 token estimator 或可测字符上限执行
- 明确定义不可裁剪消息和超预算错误
- 表驱动测试覆盖空历史、断裂工具对、长历史、敏感消息和摘要版本
- Checkpoint 内容不得被当作业务事实回写

3.6 评审清单

  • 数据库是否保存领域合同,而非直接保存 Eino 对象
  • 是否把 Run-local、Session 和 Business facts 混在一个 map
  • 历史裁剪是否可能留下孤立 Tool Result
  • 摘要是否有来源 run、版本和可追溯 evidence
  • Checkpoint 是否设置 TTL、tenant 隔离和加密要求
  • resume 后是否会重复提交已有业务副作用

3.7 闭卷检索题

用户已批准一个写操作,Agent 在工具执行前中断。批准结果、Eino checkpoint 和最终业务写入分别应该存在哪里?

3.8 一手资料


第 4 章:Tool、JSON Schema 与 ToolsNode

4.1 Tool 有两份合同

Tool 的第一份合同给模型看:名称、描述、参数 JSON Schema。第二份合同给执行系统:身份、权限、限额、幂等、审计、deadline 和错误分类。

模型生成了合法 JSON,只说明它通过了语法门槛,不说明用户有权执行。

flowchart LR
    M[Model Tool Call] --> S[Schema validation]
    S --> P[Policy / permission]
    P --> B[Broker preflight]
    B --> X[Tool execution]
    X --> A[Audit + evidence]
    A --> R[Canonical result]

4.2 工具实现形态

Eino Tool 主要按是否流式、是否需要增强输入区分:

  • Invokable Tool:JSON 输入,完整字符串输出
  • Streamable Tool:JSON 输入,流式输出
  • Enhanced Tool:除参数外还能拿到 ToolCall 上下文或更丰富输入
  • ToolsNode:读取 assistant 的 Tool Calls,查找工具并执行,产出 Tool Results

工具的 Info() 决定模型看到什么;InvokableRun 或流式方法执行真正逻辑。Schema 要尽量平坦、明确、闭合,尤其要考虑 Provider 对 $defsallOf 等特性的兼容。

4.3 Tool Schema 的写法

go
type ExecuteOperationInput struct {
    Operation string         `json:"operation"`
    Input     map[string]any `json:"input"`
}

// 骨架:实际项目应使用 Eino 提供的 Schema helper 或明确构造 ToolInfo。
func (t *ExecuteOperationTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
    return &schema.ToolInfo{
        Name: "execute_operation",
        Desc: "Execute one approved 业务操作 in the current run",
        ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
            "operation": {
                Type: schema.String, Required: true,
                Desc: "One operation from the approved closed set",
            },
            "input": {
                Type: schema.Object, Required: true,
                Desc: "Operation-specific input validated again by the broker",
            },
        }),
    }, nil
}

描述必须写行为边界,不能写“万能分析工具”。枚举、必填字段和互斥关系应尽量进 Schema,但执行前仍要用 Go 领域合同二次验证。

4.4 批量 Tool Calls 的语义

一个 assistant response 可以一次返回多个 Tool Call。先决定是否并行:

  • 纯读、互不依赖、限流允许时可并行。
  • 共享工作区、存在顺序依赖、写操作或要求稳定 evidence 顺序时应串行。

ToolsNodeConfig.ExecuteSequentially = true 可以要求按顺序执行。生产系统可以先对整批调用做 preflight,再按 FIFO 串行执行,避免第一项已经产生副作用、第二项才发现无权限。

4.5 错误要分两层

Tool 可恢复错误可以作为稳定 JSON Tool Result 返回给模型,让它改参数或换方案。此时 Go error 通常为 nil,否则 Agent loop 会直接终止。

系统错误包括事件持久化不确定、凭据异常、合同破坏和内部 panic。它们应终止 run,不能包装成普通工具输出让模型继续。

稳定错误示例:

json
{
  "schema_version": 1,
  "ok": false,
  "error": {
    "category": "runtime",
    "code": "broker_not_ready",
    "message": "The business executable is not configured.",
    "retriable": false
  }
}

不要把内部堆栈、路径、凭据或原始 Provider body 返回模型。

4.6 给 Code Agent 的任务合同

text
实现 read_skill 与 execute_operation 两个 Eino Tool wrapper:
- ToolInfo 的名称、描述、顺序和 Schema 来自已验证的 RunRequest
- wrapper 不实现业务选择,只调用 run-scoped Broker
- 整批 Tool Calls 在任何执行前完成 preflight
- ExecuteSequentially=true,严格按 ToolCallID 和响应顺序消费
- typed business failure 返回 canonical JSON 且 Go error=nil
- 审计/合同/提交状态不确定等系统错误返回 Go error
- 不记录参数正文、凭据或原始错误 body
- 测试批量拒绝无部分副作用、FIFO、取消、deadline 和非法 ToolCallID

4.7 评审清单

  • Schema 是否与 Go 执行合同一致
  • Tool 名称是否稳定且处于闭集
  • 是否把模型参数合法等同于已授权
  • 批量调用是否可能部分通过 preflight 后产生副作用
  • 串并行选择是否写进合同和测试
  • 错误是否区分可恢复业务错误与系统错误
  • Tool Result 是否泄露路径、凭据、SQL 或内部堆栈

4.8 闭卷检索题

为什么“第二个 Tool Call 没权限”最好在第一个 Tool Call 执行前就发现?这要求 Broker 提供什么接口?

4.9 一手资料


第 5 章:DeepAgent、Backend 与工作区执行

5.1 DeepAgent 解决什么问题

普通 ChatModelAgent 适合有限工具、较短步骤的任务。DeepAgent 在它之上组合了长任务常用能力:任务拆分、文件系统工具、较大工具结果卸载、上下文压缩、可选 Shell,以及通过 task 工具调用子 Agent。

DeepAgent 仍然是 Agent Runtime。它不会自动提供安全沙箱、代码仓库权限和进程审计。

go
agent, err := deep.New(ctx, &deep.Config{
    Name:          "governed_deep_agent",
    Description:   "Coordinates governed long-running analysis",
    ChatModel:     chatModel,
    Instruction:   prompt,
    ToolsConfig:   toolsConfig,
    MaxIteration:  12,
    Backend:       backend,
    Shell:         shell,
    Handlers:      handlers,
})

配置 Backend 后可以注册 read/write/edit/glob/grep 等文件工具。配置 ShellStreamingShell 后可以执行命令;两者互斥。

5.2 Backend 是能力接口,也是安全边界

Backend 抽象文件读取、写入、编辑、glob 和 grep。实现可以是内存、受限目录或远程工作区。你要额外定义:

  • 所有路径如何规范化,是否允许符号链接
  • 根目录在哪里,能否越界读取
  • 单文件、单次响应和 run 总量上限
  • 二进制、大文件和敏感文件如何处理
  • 多 run 是否共享工作区
  • 写入和删除如何审计

不要把宿主机根目录直接暴露给 DeepAgent。即便提示词写了“不要访问”,模型输出也不是权限控制。

5.3 Shell 不是普通 Tool

Shell 可以启动任意子进程,风险高于查询型工具。至少需要:

  • 命令 allow/deny policy
  • 明确工作目录和环境变量白名单
  • 无用户 shell profile 的非交互执行
  • wall timeout、输出字节上限和进程组清理
  • 网络、文件系统、CPU、内存的隔离
  • stdout/stderr 脱敏与 artifact 策略

如果业务已经有独立的 Workspace Executor,就不要再给 Eino DeepAgent 一个同等权限的本地 Shell。两个执行面会产生权限漂移、重复重试和不可比较证据。

5.4 外部工作区执行器的推荐边界

当工作区操作由独立执行器负责时,建议保持下面的调用边界:

flowchart LR
    E[Eino Agent] --> T[execute_workspace Tool]
    T --> B[Policy Broker]
    B --> O[Workspace Executor]
    O --> W[Run-scoped workspace]
    O --> A[Commands / artifacts / evidence]
    A --> B
    B --> E

Eino 决定何时需要工作区任务;Broker 验证操作;Workspace Executor 负责真实文件和命令执行。Eino 可以有只读、虚拟或 artifact-oriented Backend,但不能绕过 Broker 直接写宿主机。

5.5 什么时候选 DeepAgent

适合:需要文件工作记忆、长上下文压缩、任务清单和受控子 Agent 的复杂任务。

不适合:单个确定性流程、一次工具调用、强事务写操作、只需短 ReAct 的查询。使用 DeepAgent 会带来更多提示词、工具和状态,需要用评测证明收益。

5.6 给 Code Agent 的任务合同

text
做一个 DeepAgent filesystem 安全 spike:
- 使用内存或临时 run-scoped Backend,不接宿主机任意路径
- 只开放 read/write/glob/grep 的最小集合
- 拒绝绝对路径、路径穿越、符号链接越界和超限文件
- 不配置本地 Shell;工作区命令只能走 Fake Workspace Broker
- 记录命令合同与 evidence hash,不保存敏感正文
- 覆盖两个并发 run 的目录隔离与取消清理
- 比较普通 ChatModelAgent 与 DeepAgent 的工具数、prompt token 和完成率

5.7 评审清单

  • DeepAgent 是否因真实需求引入,而非因为模板方便
  • Backend 是否 run-scoped、路径规范化且 fail closed
  • Shell 是否绕过了 Workspace Executor 和 Broker
  • 大工具结果是否有卸载与引用方案
  • 工作区命令、artifact 与 AgentEvent 是否能用 run_id 关联
  • 取消后子进程和临时目录是否有界清理

5.8 闭卷检索题

为什么已经有 Workspace Executor 时,不应再给 Eino 一个等价的本地 Shell?至少说出两个故障后果。

5.9 一手资料


第 6 章:Compose、Chain、Graph、Workflow 与流式处理

6.1 Compose 的基本单位是 Runnable

Runnable[I, O] 表达一个有类型的计算:输入 I,输出 O。它通常支持四种调用形态:

输入 输出 常见方法
单值 单值 Invoke
单值 Stream
单值 Collect
Transform

这四种形态是内部数据流语义,不是四种 HTTP 接口。

6.2 Chain、Graph、Workflow 怎么选

Chain:线性为主,前一个输出给后一个,代码最短。适合 prompt → model → parser。

Graph:显式节点、边、分支和循环。适合 ReAct、自定义路由、可恢复执行,以及需要看到拓扑的流程。

Workflow:围绕字段映射组织 DAG。适合多个节点读取输入不同字段,再把结果装配成结构化输出。

选择最小表达力:Chain 能清楚表达就不建 Graph;需要回边或条件路由再用 Graph;重点是字段级依赖时用 Workflow。

6.3 Graph 骨架

go
type Input struct {
    Query string
}

type Output struct {
    Answer string
}

graph := compose.NewGraph[*Input, *Output](
    compose.WithGenLocalState(func(ctx context.Context) *runState {
        return &runState{}
    }),
)

_ = graph.AddLambdaNode("validate", validateLambda)
_ = graph.AddChatModelNode("generate", chatModel)
_ = graph.AddLambdaNode("format", formatLambda)

_ = graph.AddEdge(compose.START, "validate")
_ = graph.AddEdge("validate", "generate")
_ = graph.AddEdge("generate", "format")
_ = graph.AddEdge("format", compose.END)

runnable, err := graph.Compile(ctx)
result, err := runnable.Invoke(ctx, input, compose.WithCallbacks(handler))

v0.9 共享本次运行状态应从 NewGraph(...WithGenLocalState(...)) 创建。不要跟随旧文章使用已经被替代的 StateGraph/StateChain 构造方式。

6.4 Local state 不是全局缓存

Local state 应由每个 run 单独生成。常见错误是把一个指针捕获在 graph 外,所有请求共用,结果产生数据竞争和租户串线。

节点读写 state 要经过框架提供的 state 处理函数,并保持最小字段。不要把 DB client、HTTP response writer 或不可序列化的大对象塞进去。需要 checkpoint 时,自定义状态还要满足序列化要求。

6.5 流式节点的现实成本

流经过分支时,分支条件可能消费 stream。多个下游同时读取时需要复制;任何一个分支不读完或不关闭都可能卡住上游。流式图评审要画“谁创建、谁消费、谁关闭”。

如果一个中间节点必须看到完整内容才能判断,不要伪装成逐 token 流。明确在该节点 Collect,然后输出新流或单值。

6.6 Compile 是架构检查点

Graph 定义是 builder;Compile 后得到 Runnable。生产服务通常在启动期完成装配和 Compile,而不是每次请求重建图。启动期失败应进入 readiness,不要等首个用户请求才暴露。

但 per-run 的工具列表、tenant policy 和 session 数据不能通过修改共享 compiled graph 注入,应使用 option、context、run-local state 或请求级不可变依赖。

6.7 给 Code Agent 的任务合同

text
实现一个 typed Graph:validate -> fetch -> analyze -> format
- 所有节点输入输出使用明确结构体,不用 map[string]any 穿透全图
- run-local state 通过 NewGraph 的 state generator 每次创建
- 图在服务启动时 Compile,失败影响 readiness
- fetch 和 analyze 的错误分别分类,不用字符串匹配
- 同时测试 Invoke 与 Stream;记录每个 stream 的创建、消费和关闭方
- callbacks 通过 compose call option 注入
- 用 Fake nodes 做分支、取消和并发 run 隔离测试

6.8 评审清单

  • 选 Chain/Graph/Workflow 是否有具体理由
  • 节点间是否有明确类型,还是全部使用 any
  • local state 是否每个 run 新建
  • compile 是否在启动期,错误是否进入 readiness
  • 流的单消费者和关闭责任是否清楚
  • 是否在请求中修改共享 Runnable 或模型对象

6.9 闭卷检索题

一个节点需要读取完整模型输出才能决定路由。它还能声称端到端逐 token streaming 吗?应该如何表达真实语义?

6.10 一手资料


第 7 章:GraphTool,把确定性流程放进 Agent

7.1 为什么需要 GraphTool

有些工作要由 Agent 决定“是否做”,但一旦开始,内部步骤必须确定。例如:验证报告 ID → 查询数据 → 计算指标 → 校验 claim → 返回 evidence pack。把每一步都暴露为独立 Tool,会让模型自由改顺序、漏步骤或重复调用。

GraphTool 的思路是把整个 typed Graph 包成一个 Tool:外层 Agent 自主选择,内层流程按图执行。

flowchart LR
    A[Agent] -->|决定调用| GT[GraphTool: analyze_report]
    subgraph Deterministic graph
      V[Validate] --> F[Fetch]
      F --> C[Compute]
      C --> Q[Claim validation]
      Q --> P[Evidence pack]
    end
    GT --> V
    P --> GT
    GT --> A

7.2 先澄清一个包边界

Eino README 展示了 graphtool.NewInvokableGraphTool,对应实现与示例目前在 eino-examples/adk/common/tool/graphtool。它表达的是推荐模式,不应不加判断地从 examples 复制进生产。

生产有两个选择:

  • 若内部或稳定扩展已有受维护的 GraphTool,锁定它的模块与版本。
  • 若没有,自己写一个很薄的 Tool adapter:Info() 暴露输入 Schema,InvokableRun() 反序列化输入并调用已编译 Runnable。adapter 不应重新实现 Graph。

7.3 一个薄 adapter 应该长什么样

go
type AnalysisGraphTool struct {
    runnable compose.Runnable[*AnalysisInput, *AnalysisOutput]
    info     *schema.ToolInfo
}

func (t *AnalysisGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
    return t.info, nil
}

func (t *AnalysisGraphTool) InvokableRun(
    ctx context.Context,
    arguments string,
    opts ...tool.Option,
) (string, error) {
    in, err := decodeAndValidate(arguments)
    if err != nil {
        return canonicalInputError(err), nil
    }
    out, err := t.runnable.Invoke(ctx, in)
    if err != nil {
        return "", err
    }
    return encodeOutput(out)
}

这是结构骨架。真实 Tool 接口签名要按所选 classic/enhanced 形态和 v0.9.15 源码确认。

7.4 GraphTool 和子 Agent 的区别

GraphTool 内部路由由代码控制,适合必须按序、可独立单测的业务流程。子 Agent 内部仍由模型决策,适合任务描述开放、步骤难以预先枚举的工作。

如果一个“子 Agent”只能走固定的五步,它更像 GraphTool。如果一个 Graph 中大部分节点只是让模型决定下一步,它可能更适合普通 Agent。

7.5 业务应用中的候选 GraphTool

  • build_evidence_pack:读数据、规范化、hash、验证完整性
  • validate_claims:逐 claim 检查来源与计算口径
  • prepare_workspace_task:验证任务合同、生成只读 snapshot、提交 Workspace Executor
  • finalize_run_artifacts:闭合事件、生成 final 与 validation

最后一项通常更适合 Supervisor 固定执行,而不是让模型决定是否调用。这也是边界判断:涉及 run 终态完整性的步骤不能依赖模型自觉。

7.6 给 Code Agent 的任务合同

text
把 build_evidence_pack 实现为 Graph + Tool adapter:
- 外层 Agent 只看到一个工具
- 图内节点为 validate/fetch/normalize/hash/verify
- 输入输出是版本化结构体
- 图启动时 Compile;Tool adapter 只做 decode、invoke、encode
- 所有节点无隐式 retry,无直接 SSE 写入
- 验证失败不生成“成功” evidence pack
- Fake repository 下做确定性单测,并验证任一步失败不执行后续副作用
- 说明 GraphTool 实现来源与版本,禁止直接复制未维护示例

7.7 评审清单

  • 该流程是否真需要模型决定内部步骤
  • GraphTool adapter 是否薄,业务逻辑是否仍在 Graph 节点
  • 输入输出是否可版本化、可独立验证
  • Graph compile 是否复用,run state 是否隔离
  • 失败后是否错误地产生完整 artifact
  • 必做的 run 终态步骤是否被放进可选 Tool

7.8 闭卷检索题

“生成 final.json 并关闭 run”为什么不应设计成模型可选的 GraphTool?

7.9 一手资料


第 8 章:Handlers、中间件、重试与故障切换

8.1 v0.9 的推荐扩展点

ChatModelAgent 支持基于接口的 Handlers []adk.ChatModelAgentMiddleware。自定义 handler 通常嵌入 *adk.BaseChatModelAgentMiddleware,只覆盖需要的方法。

go
type AuditHandler struct {
    *adk.BaseChatModelAgentMiddleware
    sink AuditSink
}

func (h *AuditHandler) WrapInvokableToolCall(
    ctx context.Context,
    next adk.InvokableToolCallEndpoint,
    tc *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
    return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
        // before: 只记录安全元数据
        result, err := next(ctx, args, opts...)
        // after: 按提交语义记录终态
        return result, err
    }, nil
}

旧的 struct/closure 扩展方式已经标记为 deprecated。新服务应实现 interface-based handler,不应为了少写几个方法继续建立旧接口依赖。

8.2 Handler 的四类工作

  • BeforeAgent / AfterAgent:运行级装配和收尾
  • BeforeModelRewriteState / AfterModelRewriteState:修改可持久的模型输入状态、动态 ToolInfo
  • WrapModel:模型调用周围的计时、事件和响应处理
  • Wrap...ToolCall:工具调用周围的授权、审计和结果处理

动态工具选择应改 state 中的 ToolInfo/DeferredToolInfo,或在 BeforeAgent 改可执行工具集合。不要在 WrapModel 临时修改工具绑定:变化不会成为 Agent state 的事实,还会破坏 prompt cache。

8.3 包装顺序会改变语义

Handlers 按注册顺序包装,第一项在最外层。若配置 [A, B, C],实际是 A(B(C(target)))。因此:

  • before 顺序通常是 A → B → C
  • after 顺序通常是 C → B → A
  • 事件发送器放在哪里,决定它看到原始输出还是清洗后的输出

评审不能只看“注册了审计 handler”,还要看它在脱敏、retry、failover 和事件发送器的哪一层。

8.4 Retry 必须按操作语义设计

模型读调用在满足条件时可以 retry。写工具不能因为网络超时就盲目 retry,因为第一次可能已经提交。判断矩阵:

操作 自动 retry 条件
模型 Generate Provider 明确可重试、预算允许、没有流式部分提交
只读 Tool 幂等、deadline 有余量、错误明确为调用前失败
写 Tool 有 idempotency key,且下游可查询提交状态
事件写入 只有 writer 明确证明未提交时才可重试同一事件

max_retries 是上限,不是“必须至少重试这么多次”。建议默认关闭隐式模型和工具 retry;只有合同、预算与幂等条件明确后才单独启用。

8.5 Failover 不是 retry 的别名

Retry 对同一模型重试;failover 选择另一个模型。换模型可能改变 Tool Calling、JSON Schema 支持、token 口径和内容安全行为。必须定义:哪些错误允许换、备选模型是否合同等价、usage 如何累计、事件如何记录。

8.6 给 Code Agent 的任务合同

text
实现 interface-based Agent handlers:
- registration order 固定为 policy -> audit -> metrics -> sanitizer
- 用测试证明 before/after 的真实顺序
- 动态工具过滤修改持久 state,不在 model wrapper 临时绑定
- retry policy 按 model/read-tool/write-tool/event-writer 分开
- 默认 retry=0;启用必须写预算和幂等依据
- failover 只对闭集错误开放,并在事件中记录实际 provider/model
- 日志和事件不得包含原始工具参数与 reasoning

8.7 评审清单

  • 新代码是否使用 interface-based Handlers
  • wrapper 顺序是否有测试,而非靠注释猜
  • 动态工具列表是否同时影响“模型可见”和“实际可执行”
  • retry 是否可能重复副作用
  • failover 模型是否真的满足相同 Tool Schema 合同
  • retry/failover 次数是否计入 turn、token、wall-time 预算

8.8 闭卷检索题

一个写工具返回超时,但不能证明下游未提交。为什么不能自动 retry?下一步应该是什么?

8.9 一手资料


第 9 章:Callback、Trace、指标与资源生命周期

9.1 Callback 和 Handler 不重复

Handler 改变 Agent 的运行行为,知道 Agent state 和 Tool context。Callback 观察 Component 生命周期,适合统一 trace、日志和指标。

判断方法:如果逻辑要决定“允不允许工具执行”,用 Handler/Broker;如果只记录“模型调用用了多久”,用 Callback。不要把权限判断藏在一个全局观测 callback 里。

9.2 观测需要三层 ID

  • trace_id:跨服务调用链
  • run_id:一次 Agent 运行的业务身份
  • model_call_id / tool_call_id:一次具体 attempt

只有 trace_id 无法回答“这个 run 的第二个工具是否闭合”。只有 run_id 又无法定位跨服务延迟。三层要在事件和安全日志中关联。

9.3 Callback 接入

全局 callback 用追加式注册函数接入;单次 Compose 调用用 compose.WithCallbacks。不要调用旧的全局覆盖式初始化函数,否则可能把其他库已注册的 handler 清掉。

go
callbacks.AppendGlobalHandlers(globalTraceHandler)

out, err := runnable.Invoke(
    ctx,
    input,
    compose.WithCallbacks(runHandler),
)

全局 handler 必须线程安全,也不能保存某个租户或某个 run 的可变状态。

9.4 指标先回答运营问题

建议最少有:

类别 指标
Run started/completed/failed/cancelled/timed_out、duration
Model calls、first output latency、duration、tokens、provider errors
Tool requested/started/succeeded/failed、duration、denied
Queue wait duration、active runs、rejected
Artifact validation failure、evidence missing、writer uncertainty

标签必须是低基数闭集。不要把 run_id、query、error message 或文件路径放进 metrics tag。

9.5 流式首包不能只测模型

用户感知首包是:请求进入 → 排队 → context 构造 → 首次模型调用 → 适配产品事件 → SSE flush。应分别测量模型首次输出与端到端 TTFT,并用 run/model call ID 对齐。

9.6 不可变输入与 stream close

观测代码最容易引入两个隐蔽 bug:

  1. 为加标签修改输入 message 或 option,导致其他消费者看到变化。
  2. 为记录流内容先读一次,业务消费者随后读到空流。

Callback 应把输入视为不可变。需要记录流式统计时,用框架支持的复制/tee 机制,并确保每个 reader 在正常、错误、取消路径上都关闭。

9.7 给 Code Agent 的任务合同

text
实现 Eino observability adapter:
- 全局 callback 用 append 语义注册,不能覆盖已有 handlers
- per-run callback 通过 compose option 注入
- trace_id/run_id/model_call_id/tool_call_id 可关联
- metrics tags 只允许闭集字段
- 不记录 prompt、reasoning、tool args、provider body 和凭据
- callback 不修改输入,不抢先消费单消费者 stream
- 测试正常、EOF、模型错误、工具错误和取消下所有 reader 关闭
- 分别测 Eino output-stream-start 与 HTTP SSE first-flush

9.8 评审清单

  • Callback 是否只观测,还是暗中改变授权和重试
  • 全局注册是否覆盖别人的 handler
  • metrics 是否出现高基数或敏感标签
  • trace、run 与 attempt 是否可关联
  • 是否把 model stream 首块误当端到端首包
  • callback 是否修改输入或消费唯一 stream
  • 错误路径是否也闭合 span、事件和 reader

9.9 闭卷检索题

模型在 300ms 产出首块,但用户 3s 才看到 SSE。只看 Eino 模型指标能定位问题吗?还需要哪些时间点?

9.10 一手资料


第 10 章:运行时 Skill 与 ToolSearch

10.1 两种 Skill 再分一次

Skill 谁使用 何时运行 目的
Code Agent Eino Skill 写代码的 Agent 开发期 查 Eino 用法、生成或审查代码
ADK Skill Middleware 你的业务 Agent 用户 run 期间 按需加载任务说明或派生子 Agent

开发期 Skill 不会随业务服务部署。运行时 Skill 也不应教 Code Agent 怎么改 Go 代码。

10.2 Skill Middleware 的模型

每个运行时 Skill 是一个目录中的 SKILL.md,frontmatter 包含 name、description、context、agent、model 等字段。Middleware 先列出元数据,Agent 决定加载哪个 Skill,再按模式处理正文:

  • inline:把 Skill 内容作为工具结果回到当前 Agent
  • fork:启动一个不带父历史的子 Agent
  • fork_with_context:子 Agent 带父历史
go
skillHandler, err := skill.NewMiddleware(ctx, &skill.Config{
    Backend:  skillBackend,
    AgentHub: agentHub,
    ModelGateway: modelGateway,
})

agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Model:    chatModel,
    Handlers: []adk.ChatModelAgentMiddleware{skillHandler},
})

Skill Backend 决定从哪里列出和读取 Skill。filesystem backend 只扫描 base dir 的第一层子目录。生产中还要做版本、签名、tenant 可见性和内容安全校验。

10.3 Skill 不是权限

Skill 文本可以说“只读数据库”,但真正的工具集合与 Broker policy 必须落实只读。Prompt 约束只能影响模型选择,不能阻止恶意或错误的 Tool Call。

加载 Skill 还会改变 prompt,因此需要记录:skill name、内容版本/hash、加载模式、实际 Agent/model。不要记录可能含敏感信息的全文。

10.4 ToolSearch 解决工具规模问题

一次把数百个 Tool Schema 塞进模型会增加 token、降低选择质量,也可能超过 Provider 限制。ToolSearch/Deferred Tools 允许先暴露搜索工具和延迟加载的 ToolInfo,模型找到候选后再调用。

但 ToolSearch 只解决“模型看到什么”。执行系统仍必须用当前 run 的批准工具闭集校验,防止模型搜索到租户无权使用的工具。

工具检索应按名称、描述、domain、权限标签和版本做确定性过滤。检索结果也要受数量上限控制。

10.5 业务应用可以怎样用

  • Skill 表达某类分析方法、证据要求和输出合同
  • ToolSearch 从大规模 业务操作目录 中找少量候选
  • Broker 根据 run contract、tenant 和数据域做最终授权
  • Evidence 记录 Skill 与 Tool 定义版本,支持回放解释

Skill 内容和 Tool Schema 都应该版本化。仅记录名称不足以重放,因为同名内容可能已经更新。

10.6 给 Code Agent 的任务合同

text
实现运行时 Skill catalog spike:
- 只读取批准目录第一层的 SKILL.md
- frontmatter 做闭合 Schema 校验,name 唯一
- List 只返回安全元数据,Get 返回内容并计算版本 hash
- inline/fork/fork_with_context 分别做隔离测试
- ToolSearch 先按 tenant policy 过滤,再返回最多 N 个候选
- 搜索结果不能扩大 Broker 的 executable tool set
- run artifact 记录 skill/tool definition hash,不记录敏感全文
- 文档中明确区分开发期 Eino Skills 与运行时 Skills

10.7 评审清单

  • Skill 来源是否可信、版本是否可追溯
  • fork 是否意外继承父历史或敏感 context
  • Skill 是否被误当权限控制
  • ToolSearch 是否先做 tenant/policy 过滤
  • 返回候选数和 Schema token 是否有上限
  • 运行 artifact 能否说明实际加载了哪版 Skill

10.8 闭卷检索题

一个 Skill 写着“允许调用 write_report”。为什么 Agent 仍不能直接得到该写工具?

10.9 一手资料


第 11 章:Interrupt、Resume、Checkpoint 与人工确认

11.1 中断不是失败

Interrupt 表示 Agent 需要外部输入才能继续,例如用户批准写操作、补充参数、选择候选数据源。Runner 保存恢复所需状态并产出结构化动作。应用把它映射为 waiting_for_inputwaiting_for_approval,而不是 failed

sequenceDiagram
    participant U as User
    participant A as Application service
    participant R as Eino Runner
    participant S as Checkpoint store
    R->>S: save checkpoint(checkpoint_id)
    R-->>A: interrupted action + safe info
    A-->>U: approval requested
    U->>A: decision + idempotency key
    A->>R: ResumeWithParams(checkpoint_id, targets)
    R->>S: load checkpoint
    R-->>A: continued events

11.2 两种恢复

Runner.Resume(checkpointID) 是隐式恢复全部,适合单一确认点。ResumeWithParams 可以按 interrupt address 提供数据,适合多个或嵌套中断点。

go
iter, err := runner.ResumeWithParams(ctx, checkpointID, &adk.ResumeParams{
    Targets: map[string]any{
        interruptAddress: ApprovalDecision{
            Approved: true,
            ActorID:  actorID,
        },
    },
})

创建首次运行时,应给每个业务 run 分配不可猜测、租户隔离的 checkpoint ID。不要直接使用用户可控字符串。

11.3 Checkpoint 的正确地位

Checkpoint 包含框架恢复状态,可能有消息、local state 和嵌套 Agent 数据。它必须有:

  • tenant/run ownership 校验
  • TTL 与删除策略
  • 静态加密和访问审计
  • Schema/版本兼容策略
  • 最大体积与序列化失败处理

它不能替代业务审批表。审批事实要单独持久化,包含 actor、decision、scope、request hash 和时间。恢复时先读取并验证业务事实,再把受控 resume data 交给 Eino。

11.4 Resume 最危险的是重复副作用

恢复可能从 Tool 边界重入。所有写操作都应带业务幂等键,例如 run_id + tool_call_id + operation_version。下游要能回答:未执行、已成功、已失败、提交状态未知。

如果上次提交状态未知,不能靠 resume 再执行一次。先查询下游状态,仍未知就进入人工处理或失败终态。

11.5 取消和中断不同

中断期待未来 resume;取消表示调用方不再需要当前执行。不要为取消自动生成一个可恢复审批。产品可以允许“重新开始”,那是新 run,不是旧 run 的 resume。

11.6 给 Code Agent 的任务合同

text
实现 HITL approval 流:
- 首次 run 在写工具前产出结构化 interrupt
- checkpoint_id 由服务生成并绑定 tenant/run
- approval 独立存储 actor/scope/request_hash/decision/version
- resume 前验证审批未过期且与原请求 hash 相同
- ResumeWithParams 只传最小 decision,不传 HTTP DTO
- 写工具用 run_id+tool_call_id+operation_version 做幂等键
- 测试拒绝、过期、重复 resume、跨租户、篡改 request 和提交状态未知
- checkpoint 设置 TTL,终态后按策略删除

11.7 评审清单

  • 中断是否有结构化原因与最小安全展示信息
  • checkpoint ID 是否可枚举或跨租户读取
  • 审批事实是否只存在 checkpoint
  • resume data 是否未经验证直接进入工具
  • 写操作是否能安全处理重复 resume
  • run 终态是否清理或过期 checkpoint

11.8 闭卷检索题

为什么批准结果必须单独存业务表,不能只依赖 Eino checkpoint?

11.9 一手资料


第 12 章:TurnLoop、排队、抢占、取消与停止

12.1 为什么单次 Runner 不够

聊天产品可能在一个 Agent turn 尚未完成时收到新消息。你需要决定:排到后面、合并进当前计划、抢占当前 turn,还是拒绝。TurnLoop[T, M] 管理 item buffer,并为每个 turn 准备 Agent 和输入。

核心回调:

  • GenInput:从 buffered items 中决定本轮消费哪些、保留哪些
  • PrepareAgent:按本轮 items 创建或取得 Agent
  • OnAgentEvents:消费本轮事件
  • GenResume:存在 checkpoint 时决定如何恢复
go
loop := adk.NewTurnLoop(adk.TurnLoopConfig[InboundItem, *schema.Message]{
    GenInput:      genInput,
    GenResume:     genResume,
    PrepareAgent:  prepareAgent,
    OnAgentEvents: consumeEvents,
    Store:         checkpointStore,
    CheckpointID:  checkpointID,
})

loop.Run(ctx)
ok, ack := loop.Push(item, adk.WithPreempt[InboundItem, *schema.Message](adk.AnySafePoint))
loop.Stop(adk.WithGraceful())
exit := loop.Wait()

这是结构示例;option 的精确泛型推导应由锁定版本编译验证。

12.2 四种产品行为

Queue:当前 turn 完成后处理新消息。最可预测。

MergeGenInput 把多条 item 合成一个 turn。要保留 item IDs 和顺序。

Preempt:新消息入队,同时请求取消当前目标 turn。新 item 不会凭空进入当前模型上下文,而是在下一 turn 处理。

Stop:关闭 loop。无 option 时通常等当前 turn 结束;immediate/graceful/timeout 影响取消方式。若 Agent 不支持相应取消 option,行为可能退化为当前 turn 结束后退出。

12.3 Push 成功不等于抢占已生效

Push 返回 bool 表示是否进入 buffer。带 preempt 时还返回 ack channel,表示抢占请求已被解析,不代表旧 turn 已完成清理。产品状态必须区分:item accepted、preempt requested、old turn terminal、new turn started。

12.4 TurnLoop 不是全局任务队列

它适合单 session/单 Agent 的内存运行循环。服务级分布式排队仍需要应用层 queue、shard ownership、lease 和故障恢复。进程重启时,只有配置 Store 与 CheckpointID 并正确实现 item 序列化,才能恢复 TurnLoop bookkeeping。

12.5 取消的闭合原则

一旦发出 tool_requested,终态前就要闭合为 succeeded/failed/cancelled。取消不能直接跳到 run_cancelled,留下半截 Tool lifecycle。清理应使用有界 detached context,避免父 context 已取消后事件无法落盘;这个 context 只能做收尾,不能开始新业务副作用。

12.6 给 Code Agent 的任务合同

text
实现单 session TurnLoop spike:
- item 含 message_id/session_id/received_at/payload
- 默认 queue;只有显式 urgent 类型允许 preempt
- 记录 accepted/preempt_requested/old_terminal/new_started
- Stop 区分 drain、graceful、immediate,并写状态转换测试
- Store 开启时证明 item 可序列化并能进程重建恢复
- 已 requested 的工具在 run terminal 前全部闭合
- cleanup 共用有界预算,不在 detached context 启动新工具
- 并发 Push、Stop-before-Run、late items 和重复 Wait 都有测试

12.7 评审清单

  • 新消息的 queue/merge/preempt/reject 语义是否由产品冻结
  • 是否把 Push accepted 当成旧 turn 已取消
  • TurnLoop 是否被误当分布式队列
  • item 和 checkpoint 是否支持版本化序列化
  • 取消后是否留下未闭合 Tool Call
  • Stop 退化行为是否被测试和暴露

12.8 闭卷检索题

urgent 消息 Push 返回成功且 ack 已关闭,此时能否立刻把旧 run 标记 cancelled?为什么?

12.9 一手资料


第 13 章:A2UI、SSE 与产品事件边界

13.1 三类输出不要混

输出 面向谁 语义
Model stream Agent/Compose token 或 content block 增量
AgentEvent Runtime 消费者 模型、工具、动作、错误等内部运行事件
Product event Web/App 客户端 版本化、可重连、可授权的产品协议

A2UI 是 Agent 输出 UI 描述的一种方式。SSE 是传输协议。两者都属于产品 adapter,不是 Eino 自动替你冻结的业务合同。

13.2 事件适配层

flowchart LR
    AE[AgentEvent] --> N[Normalizer]
    N --> L[Run lifecycle reducer]
    L --> P[Persisted product event]
    P --> S[SSE publisher]
    P --> R[Replay API]
    N --> U[A2UI validator]
    U --> P

Normalizer 读取 Eino 事件,但输出业务应用自有类型。Reducer 验证状态转换。先持久化,再发布;只有这样客户端重连才能从 last_event_id 补发。

13.3 推荐事件信封

json
{
  "schema_version": 1,
  "event_id": 42,
  "run_id": "run_...",
  "session_id": "session_...",
  "type": "tool_started",
  "occurred_at": "2026-08-24T12:00:00Z",
  "payload": {
    "tool_call_id": "call_...",
    "tool_name": "execute_operation"
  }
}

event_id 应在 run 内单调递增。writer 只有确认事件提交后才能推进 sequence。若返回错误但提交状态不明,不能随便重写一个新 event_id;artifact 应标记不可验证。

13.4 A2UI 必须验证

模型输出的 UI tree 仍是不可信输入。需要:

  • 组件 allowlist 和 Schema version
  • 文本、URL、动作参数大小限制
  • 禁止任意脚本、HTML 和未知协议
  • action 必须映射到后端批准的 command,不由客户端直接执行 Tool
  • 旧客户端遇到未知组件有降级表示

A2UI 失败不应破坏文本回答。可以发出结构化 rendering failure,再退化为文本或固定卡片。

13.5 Eino streaming 不等于 SSE

直接把每个 token 写 SSE 会让内部 Provider 行为泄漏到产品协议,也难以做重连。建议聚合为稳定 delta 或语义事件,并限制频率。SSE 断开时要取消订阅还是取消 run,是产品决定;后台长任务可能继续运行。

13.6 给 Code Agent 的任务合同

text
实现 AgentEvent -> ProductEvent adapter:
- 对外类型不能暴露 Eino struct
- event_id 在 run 内单调,先持久化后 publish
- lifecycle reducer 拒绝非法状态转换和未闭合工具
- SSE 支持 Last-Event-ID 重放和慢消费者处理
- 客户端断开是否取消 run 由显式 policy 决定
- A2UI 只允许版本化组件闭集,禁止 script/raw HTML
- A2UI 校验失败降级为安全文本事件
- 测试断连重连、重复 publish、writer 提交未知和未知组件

13.7 评审清单

  • 外部 API 是否直接序列化 AgentEvent
  • event_id 是否在持久化成功前推进
  • SSE 断开是否无条件取消后台 run
  • 是否有 replay、慢消费者和背压策略
  • A2UI action 是否绕过后端授权
  • 未知组件和 Schema 版本是否能安全降级

13.8 闭卷检索题

为什么客户端收到 tool_started 后断线,重连不能只靠重新订阅 Eino stream?

13.9 一手资料


第 14 章:AgentTool、DeepAgent 与多 Agent 选择

14.1 首选工具式委派

v0.9 源码对 Agent transfer 和旧 Workflow/Supervisor Agent 给出明确的“不推荐”提示。多数场景应选:

  1. 单 ChatModelAgent + 普通 Tool
  2. 单 ChatModelAgent + adk.NewAgentTool 包装的专业 Agent
  3. DeepAgent 的 task/subagent 模式

工具式委派的优势是边界清楚:父 Agent 发出一个 Tool Call,子 Agent 返回一个结果。子 Agent 的 exit/transfer/break 等动作不会随意终止父 Agent;interrupt 可以跨边界传播。

14.2 AgentTool 骨架

go
specialist, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "metric_specialist",
    Description: "Explains one metric using approved evidence tools",
    Model:       specialistModel,
    ToolsConfig: specialistTools,
})

specialistTool := adk.NewAgentTool(ctx, specialist)

parent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:  "governed_orchestrator",
    Model: parentModel,
    ToolsConfig: adk.ToolsConfig{
        ToolsNodeConfig: compose.ToolsNodeConfig{
            Tools: []tool.BaseTool{specialistTool},
        },
    },
})

子 Agent 必须有非空、稳定的 Name 和 Description。默认 Tool 输入是 {"request":"..."};可以自定义输入 Schema,也可以选择传完整聊天历史。完整历史会增加隐私和 token 风险,不应默认开启。

14.3 三种方案对照

问题 普通 Tool AgentTool DeepAgent
内部步骤 代码确定 子模型自主 主/子模型长期自主
上下文 结构化参数 request 或受控历史 文件、任务、压缩上下文
成本 最低 额外模型调用 通常最高
适合 API、计算、固定流程 专业判断 长任务与工作区任务

先问能否用普通 Tool/GraphTool完成。只有子任务真的需要独立模型推理时再用 AgentTool。只有长任务能力确实能提高成功率时再用 DeepAgent。

14.4 多 Agent 的隐藏成本

  • 每个 Agent 有自己的 prompt 和 Tool Schema token
  • 父子上下文投影可能丢失或泄露信息
  • 事件与 usage 要归因到嵌套调用
  • 取消、checkpoint 和审批要跨边界传播
  • 测试空间随路由和失败组合扩大

多 Agent 架构必须用 eval 证明完成率或成本收益,不能把组织结构照搬成 Agent 结构。团队里有“数据组”和“报告组”,不代表 Runtime 里必须有两个 Agent。

14.5 业务应用的建议

初版保持一个主 Agent。业务操作目录、Workspace Executor、Evidence Validator 都先作为 Tool 或 GraphTool。若评测显示某类任务需要独立长上下文,再把该能力包装为 AgentTool。DeepAgent 可作为后续实验轨,不作为所有 run 的默认入口。

14.6 给 Code Agent 的任务合同

text
对比三种实现同一任务:GraphTool、AgentTool、DeepAgent
- 使用同一 Fake evidence source 和同一输出合同
- 记录模型 turns、tool calls、token、duration、成功/失败原因
- AgentTool 默认只传结构化 request,不传完整历史
- 验证子 Agent interrupt 可传播,exit 不终止父 Agent
- 取消后父子事件全部闭合
- 给出选择结论;没有 eval 数据不得推荐多 Agent 为默认
- 禁止使用 Agent transfer 或旧 Supervisor/Workflow Agent 方案

14.7 评审清单

  • 子任务是否真的需要模型推理
  • Name/Description 是否稳定且能准确路由
  • 是否默认传递了完整父历史
  • 父子 usage、事件和 artifact 是否可归因
  • interrupt/cancel 是否跨边界正确传播
  • 是否用 eval 证明多 Agent 的收益

14.8 闭卷检索题

一个“验证 evidence hash”的步骤该做普通 Tool、GraphTool 还是 AgentTool?说明理由。

14.9 一手资料


第 15 章:生产安全、预算、幂等、测试与评测

15.1 生产 Agent 是受监督的状态机

Prompt 不能替代状态机。一个 run 至少应有闭集终态:completedfailedcancelledtimed_out,以及非终态 waiting_for_input。每个 model/tool lifecycle 也要从 started 闭合到唯一终态。

状态转换由代码验证。模型文本不能决定 run 是成功还是等待澄清。

15.2 权限模型

每次 Tool Call 的有效权限是这些集合的交集:

text
service deployment allowlist
∩ tenant entitlements
∩ user authorization
∩ run contract tools
∩ current approval scope
∩ runtime safety policy

任一信息缺失都应拒绝。权限计算结果应不可变地绑定到 run snapshot,避免运行中配置变化使前后两次 Tool Call 使用不同边界。

15.3 预算分开计

建议冻结以下硬限制:

  • max model turns
  • max total tool calls 与每工具上限
  • max input/output/total tokens 或 cost
  • run wall timeout
  • per-model-call 和 per-tool deadlines
  • max streamed bytes、artifact bytes、workspace bytes
  • max parallel tools、active runs 和 queue depth

每个 retry 都消耗预算。清理预算不能随工具数不断重置,否则取消后可能无限收尾。

15.4 幂等和提交状态

所有副作用使用幂等键。事件 writer、Tool Broker、artifact finalizer 都要区分:

  • 确认未提交:可以用同一 identity 精确重试
  • 确认已提交:返回已有结果
  • 提交状态未知:停止自动行为,标记不可验证或进入 reconciliation

“出现 error”并不等于“没有提交”。这是分布式系统中最该坚持的规则。

15.5 安全日志与 artifact

默认不持久化:完整 prompt、reasoning、Tool 参数正文、Provider body、环境变量、授权 header、工作区未筛选文件。

建议持久化:版本化 RunRequest、安全事件流、命令元数据、模型/工具计数、usage、evidence hashes、final output、validation result。需要原始材料时建立单独的安全等级、保留期和访问审计。

15.6 测试金字塔

  1. 纯函数:Schema、状态转换、预算、错误映射。
  2. Fake Model/Tool:真实 Eino loop,离线且确定性。
  3. filesystem E2E:生成完整 artifact 并重新读取验证。
  4. 本地 HTTP wire test:验证 Provider serializer 和 transport。
  5. dev canary:只用批准凭据和只读任务。
  6. paired evaluation:在冻结合同下比较 Runtime/Prompt 方案。

不要让普通单测依赖真实模型网关。也不要用 Fake readiness 冒充真实环境 ready。

15.7 Eval 测什么

至少分开评估:

  • 任务完成与输出合同
  • Tool 选择、参数和调用顺序
  • evidence 完整性与 claim 支持
  • 拒绝、澄清和安全行为
  • 成本、延迟、turn/tool 次数
  • 取消、超时、Provider 错误下的恢复

比较两个 Runtime 时要冻结输入、模型、temperature、工具 Schema、retry 和预算。否则分数差异不能归因到 Runtime。

15.8 版本升级流程

升级 Eino 或 Provider 扩展时:阅读 release diff;编译;跑兼容测试;跑 Fake Agent E2E;跑 wire test;检查 checkpoint 兼容;做 dev canary;再跑冻结 eval。出现行为差异时更新合同或回退,不要只改测试期望。

15.9 给 Code Agent 的任务合同

text
为 Agent Runtime 建生产合同测试套件:
- 定义 run/model/tool 的合法状态转换
- 所有硬预算使用 checked arithmetic,禁止溢出
- 写操作按幂等键与提交状态处理
- 敏感字段做 allowlist 持久化,不靠事后正则删除
- Fake Model 驱动真实 Eino loop;测试取消、超时、批量工具和流关闭
- filesystem E2E 生成 run/events/commands/final/validation 并重新验证
- Provider wire test 不访问公网
- eval fixture 冻结模型参数、Tool Schema、retry 与预算
- 输出 coverage gap,不用降低断言掩盖未实现能力

15.10 评审清单

  • 状态转换是否有唯一终态和闭合验证
  • 权限是否取交集并绑定 run snapshot
  • 预算是否覆盖 retry、流字节和 cleanup
  • error 后是否盲目假设未提交
  • artifact 是否含敏感或不可解释数据
  • Fake 测试是否真的经过 Eino loop
  • paired eval 是否冻结了公平条件
  • 版本升级是否验证 wire 与 checkpoint

15.11 闭卷检索题

事件 writer 返回超时,无法判断事件是否落盘。为什么既不能用新 event_id 重发,也不能继续写 run terminal?

15.12 一手资料


附录 A:给 Code Agent 的总任务合同

以后每张任务都尽量写全这些字段:

text
mode: investigate | design | implementation | review
repo: 精确仓库根目录
branch: 当前或目标分支
scope: 允许阅读/修改的包和文件
allowed: 可做的操作
forbidden: 不能做的操作,特别是生产网络、凭据、其他仓库
baseline:
  - Go version
  - Eino/eino-ext/internal ext exact versions
  - selected message track
contracts:
  - input/output/error/event schemas
  - ownership and lifecycle
  - retry/idempotency/cancel semantics
requirements:
  - observable behaviors, not vague implementation wishes
verification:
  - compile/unit/race/offline E2E/wire/eval commands
output:
  - changed files
  - decisions and evidence
  - test results
  - residual risks and explicitly unimplemented scope

差的任务:“用 Eino 写个 Agent,支持工具和记忆。”

好的任务:“用锁定 v0.9.15 的内置 ChatModelAgent 实现 Fake Model 离线 ReAct;只注册两个 Tool wrapper;整批 preflight 后串行;应用合同不 import Eino;覆盖取消后 Tool lifecycle 闭合;不接真实 Provider。”


附录 B:架构评审速查表

框架选择

  • 简单模型调用:Model Component
  • 线性管道:Chain
  • 条件、回边、checkpoint:Graph
  • 字段依赖 DAG:Workflow
  • 自主 Tool 循环:ChatModelAgent
  • 确定性流程由 Agent 选择:GraphTool
  • 开放专业子任务:AgentTool
  • 长任务、文件工作记忆:经评测后的 DeepAgent

状态

  • Eino Message 不是数据库消息实体
  • Run-local、Session、Business facts 分开
  • 当前 Tool Call/Result 必须配对
  • Checkpoint 只用于恢复
  • 历史上下文由 projector 构建

工具与安全

  • 模型 Schema 和执行 Schema 可以分层,执行层不能放宽
  • 权限由 Broker 计算交集
  • 批量 Tool Calls 先整批 preflight
  • 写操作有幂等键和提交状态查询
  • Prompt 和 Skill 不是权限控制
  • Shell/Filesystem 必须隔离

运行与事件

  • 所有 lifecycle 闭合到唯一终态
  • Model stream、AgentEvent、ProductEvent 分开
  • event 先提交再递增 sequence 和发布
  • SSE 支持重放;断连策略显式
  • cancel 后只做有界收尾,不启动新副作用

观测与测试

  • trace/run/call ID 可关联
  • metrics 只用低基数标签
  • 不保存 prompt、reasoning、参数正文和凭据
  • Fake Model 经过真实 Eino loop
  • stream 正常、错误、取消都关闭
  • Provider 升级跑 wire test
  • 架构变化用冻结 eval 证明收益

附录 C:术语表

术语 本课程中的固定含义
Agent 接收消息并产生异步 AgentEvent 的运行单元
AgenticMessage v0.9 的 ContentBlock 型消息
AgentTool 把一个 Agent 包成父 Agent 可调用的 Tool
Broker Tool 执行前后的授权、验证、幂等与审计边界
Callback Component 级观测扩展点
Checkpoint 中断或取消后恢复框架执行所需的序列化状态
Component Model、Tool、Retriever 等原子接口
Compose 把 Component 和函数组成 typed Runnable 的编排层
Context projector 从领域历史构造本次模型上下文的应用模块
DeepAgent 带长任务、文件和子任务能力的预构建 Agent
Evidence 支持结论的可校验来源及其 hash/引用
GraphTool 把确定性 Graph 适配成 Agent Tool 的模式
Handler 改变 ChatModelAgent 模型/工具调用行为的接口扩展点
ProductEvent 业务应用对客户端提供的版本化、可持久化事件
Runner 启动、恢复 Agent 并输出事件的标准入口
RuntimeAdapter 隔离领域合同与具体 Agent Runtime 的应用接口
Session 跨 run 的业务会话及其状态
Skill Middleware 运行时按需加载 SKILL.md 的 ADK handler
Supervisor 拥有 run admission、writers、终态、验证与资源关闭的应用模块
ToolSearch 延迟发现 ToolInfo,减少一次发送给模型的工具数量
TurnLoop 管理一个连续交互中的 item buffer、turn 和抢占

课程结束后的学习顺序

第一轮只做闭卷回答:第 0、2、3、4、11、13 章。第二轮让 Code Agent 完成第 2 章的 Fake ReAct,并按源码、合同、测试的顺序阅读一个真实 Eino 项目。第三轮为自己的 Agent Engine 写运行、事件、工具和恢复合同,再开始服务实现。

有任何一章读起来像“知道每个词,但说不出边界”,就把该章的检索题单独发给我。后续教学应围绕你的答案纠偏,而不是再堆一遍资料。

由 EINO-COURSE-PUBLIC.md 生成 · 2026年8月26日 14:30