客户端开发者学 Eino(01):从一次模型调用理解 Agent 架构
1 从一个冷启动问题开始
做客户端开发时,我习惯把一个功能拆成页面状态、业务流程和数据接口。学习 Eino 时,我也先从这些熟悉的职责入手:模型、工具、Agent 和 Runner 分别负责什么,一次请求怎样在它们之间流动?
这是"客户端开发者学 Eino"系列的第 01 篇,面向熟悉应用开发、刚开始接触 Agent 的读者。我们用一个假设的冷启动分析助手,把架构图和真实调用代码对应起来。
用户向助手提出的问题是:
为什么 B 版本的冷启动比 A 版本慢?帮我找出主要变化。
如果步骤已经确定,可以直接写一个业务用例(UseCase):查询版本指标、计算差异、读取阶段耗时,再生成结果。这和客户端组织一段业务流程很接近。
但调查过程还可能继续变化:发现初始化阶段变慢后,要不要继续查询某个 SDK?资料里出现了新线索,要不要再检索文档?
Agent 在这里承担的工作,是让模型结合目标和已有结果,参与下一步行动的选择。 Eino 提供连接模型、组织工具和运行任务的框架能力,业务系统再把实际的数据与操作接进去。
2 先把 Eino 放进熟悉的架构
Eino 是 CloudWeGo 开源的 Go 语言框架,用于构建大模型应用与 Agent。本文的例子把它放在 Go 服务端,客户端负责发送问题、接收事件和展示结果。
text
客户端:页面 → ViewModel → HTTP / SSE
↓
Go 服务:请求入口 → Runner → Agent
├─ ChatModel → 模型服务
└─ Tool → 指标、日志、文档服务
先认识四个核心角色:
| 角色 | 在这个助手中负责什么 |
|---|---|
| ChatModel | 调用模型,取得文本或工具调用请求 |
| Tool | 查询指标、读取日志、检索文档等可执行能力 |
| Agent | 根据目标与执行结果,组织后续行动 |
| Runner | 启动 Agent 执行,把运行事件交给应用 |
可以借助客户端概念理解:ChatModel 像对外部服务的接口抽象,Tool 像封装后的 Repository 方法,Agent 像由模型参与决策的 UseCase。Runner 则位于应用调用 Agent 的入口。Eino 官方的 ADK(Agent Development Kit,智能体开发套件)围绕这些对象组织执行。Eino ADK 概述
这些类比对应的是职责。框架没有替应用实现指标系统,也没有自动决定页面怎样管理任务;业务能力和产品行为仍由应用提供。
3 从一段真实代码看模型接口
接口后面到底是什么
在客户端,上层代码经常依赖接口,实际请求交给具体实现。用 Kotlin 表达一段类似的依赖关系,可以写成:
kotlin
interface ModelService {
suspend fun generate(question: String): String
}
class StartupExplainer(private val model: ModelService) {
suspend fun explain(question: String): String =
model.generate(question)
}
这是客户端职责示意,不是 Eino 的 Kotlin SDK。重点在于 StartupExplainer 通过接口使用模型能力,具体怎么访问服务,由注入的实现承担。
Eino 也有模型接口。本例使用 model.BaseChatModel 表示基础调用能力,它提供 Generate 和 Stream:前者等待完整结果,后者逐段读取生成内容。模型接口源码
创建实现 持有接口 发起请求
下面是一段完整的 Go 参考代码。先看标记 ①、②、③ 的三处,其余语法可以结合后面的解释阅读。
示例依赖 Eino v0.9.14、OpenAI 模型适配器 v0.1.13,已通过编译检查;本篇没有发起在线模型调用。环境变量用于传入模型服务的 Key 和模型标识,理解流程无需实际配置它们。
go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// ① 创建具体的模型适配器。
provider, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
APIKey: os.Getenv("OPENAI_API_KEY"),
Model: os.Getenv("OPENAI_MODEL"),
})
if err != nil {
log.Fatal(err)
}
// ② 用接口类型持有这个具体对象。
var llm model.BaseChatModel = provider
systemPrompt := "你是客户端性能分析助手。" +
"只解释给定数据,不推测未提供的原因。"
question := "示例数据:A 版本冷启动 800ms,B 版本 1000ms。" +
"请计算增加的耗时和比例。"
// ③ 通过接口发起一次请求。
reply, err := llm.Generate(ctx, []*schema.Message{
schema.SystemMessage(systemPrompt),
schema.UserMessage(question),
})
if err != nil {
log.Fatal(err)
}
fmt.Println(reply.Content)
}
这里的三个动作分别是:
openai.NewChatModel(...)创建具体模型适配器,保存配置并组织客户端对象。var llm model.BaseChatModel = provider让接口类型的变量持有具体实现,类似 Kotlin 的val service: ModelService = ...。llm.Generate(...)经接口进入适配器实现,在这一步发起模型请求。成功后,reply.Content就是返回文本。
因此,创建 ChatModel 对象和调用模型,是两个不同步骤。 这里创建的是应用中的适配器对象,模型本身运行在被调用的服务中。OpenAI 适配器说明
还有一个容易疑惑的地方:Go 为什么没有写 implements?因为 Go 按方法集合判断接口实现。具体类型提供了接口要求的方法,就能赋给接口变量;上面那行赋值会由编译器检查。
消息与返回值怎样对应
SystemMessage 承载通用行为要求,本例要求模型只解释给定数据;UserMessage 承载本次问题,提供两个版本的耗时,并要求计算差值和比例。
给定 800ms 和 1000ms,期望解释是增加 200ms、相对 A 版本增加 25%。这是依据示例数据得到的预期结果,并非在线调用的实测输出。实际系统可以由程序完成确定的计算,再由模型组织说明;这里用简单数字,是为了看清输入与返回值。
读到这里,一个问题很自然:PE 是否就是写 system message?PE 是 Prompt Engineering,即提示词工程。system message 是其中一个落点,具体任务、示例和输出约定也会影响结果,工程过程还需要通过案例验证效果。提示词设计将在后续篇目展开。提示词工程概览
代码中的 ctx 则是 Go 的请求上下文,用于传递取消和截止时间,可以借助协程任务的超时机制理解。它与模型本轮看到的消息、资料分属不同概念,也与 Android 的 Context 职责不同。
到这里,我们只完成了一次文本模型调用。要回答"为什么变慢",助手还需要取得真实阶段数据,这就轮到 Tool 出场。
4 Tool 怎样连接已有业务能力
假设指标平台已经提供查询服务,可以把下面这些能力封装成工具:
| 工具 | 业务能力 |
|---|---|
query_startup_metrics |
查询某版本的启动指标 |
query_stage_duration |
查询各启动阶段的耗时 |
search_technical_docs |
检索与当前问题有关的资料 |
模型需要看到工具名称、用途和参数约定,才能提出结构化请求。例如,它可能要求调用 query_stage_duration,参数为 {"version":"B"}。这是本例的调用示意,具体协议字段取决于模型和适配方式。
程序负责校验参数、检查访问权限,再执行实际的业务方法。 模型提出的调用请求经过这一步,才会变成指标服务上的查询;查询结果随后被组织成工具结果,交回模型。
从客户端角度看,可以把 Tool 理解为 Repository 方法外面的一层适配。除了能执行方法,还要给模型提供可理解的能力说明。
工具也要区分"没有匹配数据"和"请求超时"。前者可能需要改变查询条件,后者可能需要重试;如果都返回空字符串,后续决策就缺少了必要信息。
5 Agent 怎样根据结果继续行动
单次模型调用取得一个结果,Agent 则围绕任务反复组织模型与工具执行。Eino 的 ChatModelAgent 将这类过程组织成 ReAct 循环,可以理解为模型判断与程序行动交替推进。ChatModelAgent 的职责
text
用户目标
↓
Agent 调用模型
├─ 最终答案 → 结束
└─ 工具调用请求
↓
程序执行 Tool
↓
工具结果加入消息
└─ 回到模型,决定下一步
把这个循环放回冷启动案例,可以推演一次调查过程:
- 先查 A、B 版本总耗时,确认差异。
- 再查阶段数据,发现增量主要集中在初始化阶段。
- 根据任务需要继续读取相关日志或技术资料。
- 证据足够后,给出结论、依据以及后续动作。
这是任务流程推演,阶段数据由工具提供。前面的文本调用代码只输入了总耗时,所以它展示的是整个助手中的模型调用环节。
与普通 UseCase 相比,这里的部分分支由模型结合新结果提出。应用仍需控制循环次数、总耗时、可用工具和结束条件。例如,达到查询预算后就结束执行,并在当前证据范围内返回结果。
这也是 Agent 工程需要同时关注模型与程序的原因:模型提供下一步建议,程序负责把建议变成受约束的执行过程。
6 Runner 怎样把执行过程交给界面
如果 Agent 在后台连续查询多个工具,客户端需要展示进度,也需要知道什么时候完成、失败或等待用户输入。
Runner 是运行 Agent 的入口,应用通过它取得执行事件,再将事件转换成产品协议和页面状态。下面是一种可采用的组织方式:
text
服务端 Runner → AgentEvent → HTTP / SSE 事件
↓
客户端接收事件 → 更新 UiState → 页面渲染
SSE(Server-Sent Events,服务端发送事件)是一种服务端持续向客户端推送内容的方式。客户端可以将收到的文本片段、工具进度、错误与结束信息映射为状态,再由 ViewModel 驱动页面更新。具体事件类型和展示文案由应用定义。
例如,界面展示"正在读取版本指标""正在分析阶段差异",并不意味着需要公开模型内部思维。工具执行记录、证据和明确的任务状态,已经能帮助用户理解进度。
页面退出时是否取消任务、任务能否后台继续、断线后如何查询结果,仍由产品与应用层设计。Runner 提供运行能力,生命周期策略需要与客户端交互约定一致。
7 固定步骤怎样与动态判断配合
有些步骤始终固定,比如并行查询两个版本、计算差值、按固定字段生成对比表。这类流程可以直接由程序编排。
Eino 的 Compose 用于组织步骤、分支和数据传递。在这个例子里,可以把"生成版本对比表"封装成稳定流程,再由 Agent 决定何时调用它。这样,确定的步骤交给程序执行,需要结合新线索选择方向的部分交给模型参与判断。Eino 编排说明
名称上要区分:Jetpack Compose 组织 UI,Eino Compose 组织模型、工具与数据处理步骤。二者处在不同的软件职责层。
我理解这套架构时,会先沿着一次请求看职责:模型接口负责调用,工具负责连接业务能力,Agent 组织任务推进,Runner 把执行过程交给应用。对应到代码,就能继续追问对象在哪里创建、请求何时发出、结果由谁消费。
下一篇会补齐阅读这些代码所需的 Go 基础,重点看结构体、接口、错误返回和请求上下文,再逐步进入工具调用。