前言
在构建 LLM 应用时,我们通常会面临一个选择:是直接调用 ChatModel 还是使用 Agent 框架?本文将通过 Octo 项目的一次实际重构,分享从直接调用 ChatModel 迁移到 Eino ADK(Agent Development Kit)的经验和思考。
什么是 Agent?
定义
Agent(智能体) 是一个能够自主感知环境、做出决策并执行动作的系统。与简单的 ChatModel 调用不同,Agent 具备以下核心能力:
| 能力 | 说明 | 示例 |
|---|---|---|
| 感知 | 接收用户输入和环境信息 | 用户问题、工具返回结果 |
| 推理 | 分析问题,制定解决方案 | 判断是否需要调用工具 |
| 决策 | 选择最优行动方案 | 决定调用哪个工具、如何组合结果 |
| 执行 | 执行选定的动作 | 调用 API、执行计算、生成回复 |
| 反馈 | 根据执行结果调整策略 | 工具失败时尝试其他方案 |
Agent vs ChatModel
ChatModel 工作流程:
Agent 工作流程:
核心区别:
| 维度 | ChatModel | Agent |
|---|---|---|
| 交互模式 | 单轮/多轮对话 | 自主循环决策 |
| 工具使用 | 需手动实现 | 内置支持 |
| 推理能力 | 单次推理 | 多步推理(ReAct) |
| 错误处理 | 简单重试 | 自主调整策略 |
| 扩展性 | 低 | 高(中间件、多Agent) |
什么是 ReAct?
ReAct 简介
ReAct(Reasoning + Acting) 是一种让 LLM 结合推理和行动的框架,由 Yao et al. (2022) 提出。它让模型能够:
- 推理(Reasoning):分析当前状态,制定下一步计划
- 行动(Acting):执行具体动作(如调用工具)
- 观察(Observation):获取行动结果
- 循环:基于观察继续推理,直到任务完成
ReAct 循环
ReAct 示例
假设用户问:"北京今天的天气怎么样?如果温度超过30度,推荐一个室内活动。"
关键点:Agent 自主决定何时调用工具、调用哪个工具、如何组合结果。
为什么需要引入 Agent?
ChatModel 的局限性
回到 Octo 项目,原始架构直接调用 ChatModel:
go
// llm/chat.go - 原始实现
func ChatStream(ctx context.Context, history []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
messages := make([]*schema.Message, 0, len(history)+1)
messages = append(messages, schema.SystemMessage(octoPrompt))
messages = append(messages, history...)
stream, err := tcm.Stream(ctx, messages)
return stream, errors.WithStack(err)
}
这种方式存在明显问题:
- 功能单一:无法工具调用、无法多步推理
- 耦合度高:System Prompt 硬编码、逻辑与模型紧耦合
- 扩展性差:添加功能需大量修改、无法支持多Agent
Agent 的解决方案
- 工具调用:天气查询、代码执行、数据库查询
- 多步推理:复杂问题分解、自主决策
- 扩展性:中间件支持、多Agent协作
引入 Agent 的理由:
| 需求 | ChatModel 方案 | Agent 方案 |
|---|---|---|
| 工具调用 | 手动实现 Function Calling | 内置 Tool Use |
| 复杂推理 | 单次推理,无法分解 | ReAct 循环,自主分解 |
| 错误处理 | 简单重试 | 自主调整策略 |
| 未来扩展 | 每次都需要大量修改 | 中间件、多Agent原生支持 |
项目背景
Octo 是一个基于 Go 语言的终端聊天应用,使用 Bubbletea 框架构建 TUI 界面,支持流式输出。
原始架构
重构实现
1. 初始化 Agent
新增 llm/agent.go,创建 ADK Agent:
go
package llm
import (
"context"
"github.com/cloudwego/eino/adk"
)
var agentRunner *adk.Runner
func InitAgent(ctx context.Context) error {
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "octo",
Description: "A helpful assistant that can answer questions and help with tasks.",
Instruction: octoPrompt,
Model: tcm, // 复用已初始化的 ChatModel
})
if err != nil {
return err
}
agentRunner = adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
})
return nil
}
关键点:
ChatModelAgent是 ADK 的核心组件,内置 ReAct 循环Instruction替代了原来的 System Prompt 硬编码Runner是执行 Agent 的入口,支持流式输出
2. 修改查询接口
将 llm/chat.go 中的 ChatStream 替换为 AgentQuery:
go
// llm/chat.go - 重构后
func AgentQuery(ctx context.Context, history []*schema.Message) *adk.AsyncIterator[*adk.AgentEvent] {
return agentRunner.Run(ctx, history)
}
变化分析:
- 返回值从
StreamReader[*schema.Message]变为AsyncIterator[*AgentEvent] AgentEvent包含更丰富的信息:输出、动作、错误等- 支持异步迭代,更适合 Agent 的事件驱动模型
3. TUI 层适配
在 tui/tui.go 中添加 Agent 事件处理:
go
// 新增消息类型
type agentIterMsg struct {
iter *adk.AsyncIterator[*adk.AgentEvent]
}
// OctoModel 新增字段
type OctoModel struct {
// ... 原有字段
agentIter *adk.AsyncIterator[*adk.AgentEvent]
}
// Update 方法新增处理
case agentIterMsg:
m.agentIter = msg.iter
return m, m.readAgentEvent(m.agentIter)
新增 readAgentEvent 方法处理 Agent 事件:
go
func (m *OctoModel) readAgentEvent(iter *adk.AsyncIterator[*adk.AgentEvent]) tea.Cmd {
return func() tea.Msg {
if iter == nil {
return streamMsg{isDone: true}
}
for {
event, ok := iter.Next()
if !ok {
return streamMsg{isDone: true}
}
if event.Err != nil {
return streamMsg{err: event.Err}
}
if event.Output != nil && event.Output.MessageOutput != nil {
mv := event.Output.MessageOutput
if mv.IsStreaming && mv.MessageStream != nil {
return streamMsg{stream: mv.MessageStream}
}
if mv.Message != nil {
return streamMsg{
content: mv.Message.Content,
reasoningContent: mv.Message.ReasoningContent,
}
}
}
}
}
}
4. 启动流程更新
在 setup/setup.go 中添加 Agent 初始化:
go
func initInner(ctx context.Context) error {
// ... 原有初始化
if err := llm.InitModel(ctx, config.OctoConfig.LLMConfig); err != nil {
return err
}
// 新增 Agent 初始化
if err := llm.InitAgent(ctx); err != nil {
return err
}
if err := tui.Init(ctx, config.OctoConfig); err != nil {
return err
}
return nil
}
架构对比
重构前
重构后
扩展性思考
迁移到 ADK 后,我们可以轻松扩展以下能力:
1. 工具调用(Tool Use)
go
agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Model: tcm,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: []tool.BaseTool{weatherTool, calculatorTool},
},
},
})
2. 中间件支持
go
agentRunner = adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
Middlewares: []adk.Middleware{
// 添加日志、限流、重试等中间件
},
})
3. 多Agent协作
go
// 未来可以扩展为多Agent架构
supervisorAgent, _ := adk.NewSupervisor(ctx, &adk.SupervisorConfig{
Agents: []adk.Agent{coderAgent, researcherAgent},
})
性能与体验
流式输出保持
重构后依然保持了流式输出能力:
go
if mv.IsStreaming && mv.MessageStream != nil {
return streamMsg{stream: mv.MessageStream}
}
事件驱动模型
ADK 的 AsyncIterator 提供了更灵活的事件处理:
go
for {
event, ok := iter.Next()
if !ok {
return streamMsg{isDone: true}
}
// 处理各种事件类型
}
总结
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 架构 | 直接调用 | Agent 框架 |
| 扩展性 | 低 | 高 |
| 工具调用 | 需手动实现 | 内置支持 |
| 代码量 | 较少 | 适中 |
| 未来能力 | 有限 | 无限可能 |
这次重构虽然增加了少量代码,但为项目打开了更大的可能性空间。Eino ADK 不仅提供了 Agent 的核心能力,还为未来的多Agent协作、工具调用、Human-in-the-loop 等高级特性奠定了基础。