第三章: 从 ChatModel 到 Agent:Octo 项目的架构演进

20 阅读5分钟

前言

在构建 LLM 应用时,我们通常会面临一个选择:是直接调用 ChatModel 还是使用 Agent 框架?本文将通过 Octo 项目的一次实际重构,分享从直接调用 ChatModel 迁移到 Eino ADK(Agent Development Kit)的经验和思考。

什么是 Agent?

定义

Agent(智能体) 是一个能够自主感知环境、做出决策并执行动作的系统。与简单的 ChatModel 调用不同,Agent 具备以下核心能力:

graph TB
    A[感知] --> B[推理]
    B --> C[决策]
    C --> D[执行]
    D --> E[反馈]
    E --> A
能力说明示例
感知接收用户输入和环境信息用户问题、工具返回结果
推理分析问题,制定解决方案判断是否需要调用工具
决策选择最优行动方案决定调用哪个工具、如何组合结果
执行执行选定的动作调用 API、执行计算、生成回复
反馈根据执行结果调整策略工具失败时尝试其他方案

Agent vs ChatModel

ChatModel 工作流程

graph LR
    A1[用户输入] --> B1[LLM]
    B1 --> C1[输出]

Agent 工作流程

graph LR
    A2[用户输入] --> B2[LLM推理]
    B2 --> C2{需要工具?}
    C2 -->|是| D2[调用工具]
    D2 --> E2[工具结果]
    E2 --> B2
    C2 -->|否| F2[输出回复]

核心区别

维度ChatModelAgent
交互模式单轮/多轮对话自主循环决策
工具使用需手动实现内置支持
推理能力单次推理多步推理(ReAct)
错误处理简单重试自主调整策略
扩展性高(中间件、多Agent)

什么是 ReAct?

ReAct 简介

ReAct(Reasoning + Acting) 是一种让 LLM 结合推理和行动的框架,由 Yao et al. (2022) 提出。它让模型能够:

  1. 推理(Reasoning):分析当前状态,制定下一步计划
  2. 行动(Acting):执行具体动作(如调用工具)
  3. 观察(Observation):获取行动结果
  4. 循环:基于观察继续推理,直到任务完成

ReAct 循环

graph TB
    A[用户问题] --> B[Thought: 推理]
    B --> C[Action: 执行动作]
    C --> D[Observation: 观察结果]
    D --> E{任务完成?}
    E -->|否| B
    E -->|是| F[最终回复]

ReAct 示例

假设用户问:"北京今天的天气怎么样?如果温度超过30度,推荐一个室内活动。"

sequenceDiagram
    participant U as 用户
    participant A as Agent
    participant T as 天气工具
    participant R as 推荐工具
    
    U->>A: 北京天气?30度以上推荐室内活动
    
    Note over A: Thought: 需要先查北京天气
    
    A->>T: get_weather("北京")
    T-->>A: 32°C, 晴
    
    Note over A: Thought: 32度>30度,需要推荐室内活动
    
    A->>R: recommend_indoor_activity("北京", "32°C")
    R-->>A: 推荐:国家博物馆
    
    Note over A: Thought: 已获取所有信息,可以回复
    
    A-->>U: 北京今天32°C,晴。推荐去国家博物馆避暑。

关键点:Agent 自主决定何时调用工具、调用哪个工具、如何组合结果。

为什么需要引入 Agent?

ChatModel 的局限性

回到 Octo 项目,原始架构直接调用 ChatModel:

// 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 界面,支持流式输出。

原始架构

graph LR
    A[用户输入] --> B[TUI Bubbletea]
    B --> C[ChatModel Stream]
    C --> D[流式输出到终端]

重构实现

1. 初始化 Agent

新增 llm/agent.go,创建 ADK Agent:

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

// 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 事件处理:

// 新增消息类型
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 事件:

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 初始化:

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
}

架构对比

重构前

graph LR
    A[用户输入] --> B[TUI]
    B --> C[ChatStream]
    C --> D[StreamReader]
    D --> E[流式输出]

重构后

graph LR
    A[用户输入] --> B[TUI]
    B --> C[AgentQuery]
    C --> D[Runner.Run]
    D --> E[AsyncIterator]
    E --> F[AgentEvent]
    F --> G[MessageOutput]
    G --> H[流式/完整消息]

扩展性思考

迁移到 ADK 后,我们可以轻松扩展以下能力:

1. 工具调用(Tool Use)

agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Model: tcm,
    ToolsConfig: adk.ToolsConfig{
        ToolsNodeConfig: compose.ToolsNodeConfig{
            Tools: []tool.BaseTool{weatherTool, calculatorTool},
        },
    },
})

2. 中间件支持

agentRunner = adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           agent,
    EnableStreaming: true,
    Middlewares: []adk.Middleware{
        // 添加日志、限流、重试等中间件
    },
})

3. 多Agent协作

// 未来可以扩展为多Agent架构
supervisorAgent, _ := adk.NewSupervisor(ctx, &adk.SupervisorConfig{
    Agents: []adk.Agent{coderAgent, researcherAgent},
})

性能与体验

流式输出保持

重构后依然保持了流式输出能力:

if mv.IsStreaming && mv.MessageStream != nil {
    return streamMsg{stream: mv.MessageStream}
}

事件驱动模型

ADK 的 AsyncIterator 提供了更灵活的事件处理:

for {
    event, ok := iter.Next()
    if !ok {
        return streamMsg{isDone: true}
    }
    // 处理各种事件类型
}

总结

维度重构前重构后
架构直接调用Agent 框架
扩展性
工具调用需手动实现内置支持
代码量较少适中
未来能力有限无限可能

这次重构虽然增加了少量代码,但为项目打开了更大的可能性空间。Eino ADK 不仅提供了 Agent 的核心能力,还为未来的多Agent协作、工具调用、Human-in-the-loop 等高级特性奠定了基础。

参考资料

项目源码:github.com/aethelgards…