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

前言

在构建 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[输出回复]

核心区别

维度 ChatModel Agent
交互模式 单轮/多轮对话 自主循环决策
工具使用 需手动实现 内置支持
推理能力 单次推理 多步推理(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:

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

原始架构

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

重构实现

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
}

架构对比

重构前

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)

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 等高级特性奠定了基础。

参考资料

项目源码:github.com/aethelgards...

相关推荐
江华森1 小时前
深入理解 Flask 实现原理
后端·python·flask
Nturmoils1 小时前
LEFT JOIN 到底什么时候会被优化器偷偷改写成 INNER JOIN
后端
北冥you鱼2 小时前
Go Modules 使用指南:从入门到精通
开发语言·后端·golang
嘟嘟07172 小时前
从零理解 MCP:手写一个本地 MCP Server 并接入 LangChain Agent
前端·后端
沈七2 小时前
Go 补强第三天:给 TaskStore 写单元测试
后端
范闲2 小时前
第二章:Octo v0.1.1:从「能用」到「好用」——终端 AI 聊天体验的全面升级
人工智能·后端
贰先生2 小时前
Xiuno BBS 审计之问题03:附件上传高危风险
后端
Ai拆代码的曹操2 小时前
记一次 K8s Pod Pending 排查:两层坑叠加,从资源到存储的完整复盘
后端
贰先生2 小时前
Xiuno BBS 审计之问题04:Cookie 缺少 HttpOnly/Secure/SameSite 安全属性
后端