第一章:从零构建终端 AI 助手 —— Octo 项目实战

7 阅读13分钟

LLM 智能体开发系列 · 第一章

本章以一个完整的终端 AI 聊天助手为例,带你从零搭建一个可运行的 LLM 应用。我们不造轮子,而是把正确的轮子装到正确的位置。


1. 我们要做什么?

在大模型时代,最简单的智能体形态就是一个对话助手——接收用户输入,调用 LLM API,返回生成结果。但"能跑"和"工程化"之间隔着一道鸿沟:

  • 配置散落在代码各处,换个模型要改好几个文件
  • API 调用没有超时和错误处理,网络一抖就崩
  • 日志全靠 fmt.Println,出问题无从排查
  • 没有结构,所有代码堆在 main.go

Octo 就是为了解决这些问题而生的。它是一个用 Go 编写的终端 AI 助手,麻雀虽小,五脏俱全:

能力实现方式
配置管理YAML 配置 + 结构体校验
LLM 对接OpenAI 兼容 API + 流式输出
终端界面Bubbletea TUI 框架
日志系统slog + 按级别分文件
应用编排setup 层统一初始化

2. 技术选型:为什么是这些?

在动手写代码之前,每个技术决策都值得被审视。选型不是追新,而是在当下约束下找到最合适的解。

2.1 开发语言:Go vs Python

维度GoPython
部署形态单二进制,零依赖需要 runtime + venv + 依赖安装
启动速度毫秒级秒级(import 链长)
内存占用低,适合常驻进程高,尤其是多包场景
LLM 生态成熟中(Eino、langchaingo)极其丰富(LangChain、LlamaIndex…)
并发模型goroutine 天然适合流式处理asyncio 回调地狱 / trio
TUI 能力Bubbletea 生态完善rich / textual 可用但受限
学习曲线简单直接简单但动态类型易出错

选择 Go 的理由

Octo 是一个终端 CLI 工具。Go 编译出的单二进制文件意味着用户下载即用,不需要装 Python、配虚拟环境、处理依赖冲突。对于 LLM 应用来说,Go 的 goroutine 天然适合处理流式响应——每个 stream.Recv() 都是一个轻量级的异步操作,不需要 asyncio 的心智负担。

结论:如果你的 LLM 应用是服务端/Web,Python 生态更丰富;如果是 CLI/边缘/嵌入式场景,Go 的部署优势是决定性的。


2.2 LLM 框架:Eino vs 其他方案

Go 生态中对接 LLM 有三条路:

方案优点缺点适用场景
原始 HTTP完全可控,零依赖需自己实现流式解析、重试、协议极简场景,只需一个 API
go-openai官方 SDK 风格,API 稳定仅限 OpenAI 协议,扩展性弱只用 OpenAI/GPT
langchaingoLangChain Go 版,Chain 概念社区活跃度低,文档不足已有 Python LangChain 经验
Eino (CloudWeGo)统一 ChatModel 接口 + Tool Calling + 流式文档偏少,示例不够丰富需要多模型切换 + 工具调用

选择 Eino 的理由

graph TD
    A[你的代码] -->|统一接口| B[ChatModel]
    B --> C[OpenAI]
    B --> D[DeepSeek]
    B --> E[通义千问]
    B --> F[Claude]
    B --> G[Tool Calling]
    B --> H[流式输出]

Eino 的核心价值是接口统一ToolCallingChatModel 接口同时支持对话和工具调用,底层通过 eino-ext 适配不同 API 提供商。当前版本只用了 Stream() 方法,但接口本身已经为 Tool Use 预留了完整能力——第二章引入工具调用时,不需要换框架,只需在接口上扩展

此外,Eino 由字节跳动 CloudWeGo 团队维护,底层依赖的 meguminnnnnnnnn/go-openai 在流式解析上做了大量边界处理,比自己手写 HTTP 调用可靠得多。

结论:原始 HTTP 适合学习,go-openai 适合只用 OpenAI,Eino 适合需要多模型 + 工具调用的生产级智能体。


2.3 TUI 框架:Bubbletea vs 其他方案

框架架构模型组件生态社区活跃度适合场景
BubbleteaElm (MVU)bubbles + lipgloss极高(Charm 全家桶)复杂交互式 TUI
tview直接式(构建后运行)丰富(表格、树、模态框)中等管理后台、仪表盘
termuiWidget 堆叠基础简单状态展示
自绘 ANSI无框架极简输出

选择 Bubbletea 的理由

Octo 的 TUI 需要处理异步流式消息——用户按下 Enter 后,界面需要实时更新 LLM 的流式输出,同时还能响应 Ctrl+C 退出。这种"状态驱动 + 异步更新"的需求,恰好是 Elm 架构的强项:

graph LR
    A[KeyPressMsg<br/>'enter'] --> B[Update]
    B --> C[Model 状态更新]
    C --> D[View 重新渲染]
    D --> E[终端输出]
    E -->|streamMsg 到达| B
    E -->|KeyPressMsg| B

Bubbletea 的 tea.Cmd 机制天然支持异步操作——startStream() 返回一个 tea.Cmd,框架会在 goroutine 中执行并把结果作为 tea.Msg 送回 Update。这避免了手写 goroutine 管理的复杂性。

Charm 生态的 bubbles 提供现成的输入框、列表等组件,lipgloss 提供声明式样式——Octo 的 ASCII Logo 渐变色就是用 ANSI 转义码实现的,配合 lipgloss 可以更优雅地管理。

结论:如果你需要构建有状态、有交互的终端界面,Bubbletea 是 Go 生态的不二之选。简单展示用 termui 就够了。


2.4 配置格式:YAML vs 其他

格式可读性Go 生态支持支持注释支持复杂嵌套文件大小
YAML极高yaml.v3 成熟中等
TOMLtoml.v2 可用一般
JSON中等标准库
ENVos.Getenv

选择 YAML 的理由

Octo 的配置需要支持嵌套结构(llm.modellog.level),需要注释(用户需要知道每个字段的含义),需要高可读性(用户直接编辑)。YAML 在这三项上都是最优解。

# 这个配置文件就是 Octo 的"说明书"
llm:
  model: 'deepseek-v4-flash'  # 支持任何 OpenAI 兼容 API
  base_url: 'https://api.deepseek.com'
  api_key: 'sk-xxx'
  timeout: 300  # 秒

log:
  level: 1       # 1=Debug, 2=Info, 3=Warn, 4=Error
  format: text   # text 或 json
  log_dir: .logs

结论:YAML 是配置文件的事实标准。TOML 适合简单场景,JSON 不适合手写配置。


2.5 日志库:slog (stdlib) vs 其他方案

来源性能结构化日志自定义 Handler零依赖
slogGo 标准库
zapUber极高一般
zerologRS极高一般
logrusSirupsen一般

选择 slog 的理由

Octo 是一个 CLI 工具,不是高并发微服务。日志量级不需要 zap/zerolog 的极致性能优化。而 slog 作为 Go 1.21+ 的标准库,有几个关键优势:

  1. 零外部依赖——不引入额外的 go.mod 条目
  2. Handler 接口开放——Octo 的 LevelFileHandler 就是基于 slog.Handler 接口自定义的
  3. MultiHandler 支持——一行代码实现控制台 + 文件双输出
  4. 与 Go 生态一致——其他库的 slog.Logger 可以直接注入使用
// slog 的 Handler 接口只有 4 个方法,实现成本极低
type Handler interface {
    Enabled(context.Context, Level) bool
    Handle(context.Context, Record) error
    WithAttrs([]Attr) Handler
    WithGroup(string) Handler
}

结论:新项目优先用 slog。只有在性能是瓶颈时才考虑 zap/zerolog。Octo 选择 slog 是因为"够用且无依赖"。


2.6 选型决策图

mindmap
  root((技术选型))
    语言: Go
      单二进制部署
      goroutine 流式处理
      TUI 生态成熟
    LLM: Eino
      统一 ChatModel 接口
      多模型切换
      Tool Calling 预留
    TUI: Bubbletea
      Elm 架构
      异步消息驱动
      Charm 全家桶
    配置: YAML
      可读性最佳
      支持注释
      嵌套结构自然
    日志: slog
      标准库零依赖
      Handler 可扩展
      MultiHandler 聚合

3. 架构总览

先看全景图,再逐层拆解:

graph TB
    subgraph 应用层
        main[main.go<br/>入口]
        setup[setup/<br/>初始化编排]
    end

    subgraph 功能层
        config[config/<br/>配置加载]
        llm[llm/<br/>模型对接]
        tui[tui/<br/>终端界面]
    end

    subgraph 基础层
        structs[structs/<br/>数据定义]
        log[setup/init_log.go<br/>日志系统]
    end

    subgraph 外部依赖
        eino[CloudWeGo Eino<br/>LLM 框架]
        bubbletea[Charm Bubbletea<br/>TUI 框架]
        yaml[YAML v3<br/>配置解析]
    end

    main --> setup
    setup --> config
    setup --> llm
    setup --> tui
    setup --> log

    config --> structs
    llm --> structs
    tui --> structs
    tui --> llm

    llm --> eino
    tui --> bubbletea
    config --> yaml

启动流程是一条清晰的初始化链:

sequenceDiagram
    participant M as main.go
    participant S as setup
    participant C as config
    participant L as llm
    participant T as tui

    M->>S: Init(ctx)
    S->>C: LoadConfig(ctx)
    C-->>S: OctoConfig
    S->>S: InitLog(&LogConfig)
    S->>L: InitModel(ctx, LLMConfig)
    L-->>S: ChatModel ready
    S->>T: Init(ctx, OctoConfig)
    T-->>S: TUI running
    S-->>M: (user exits)

4. 项目结构

octo/
├── main.go                  # 入口,仅调用 setup.Init
├── go.mod                   # Go 1.26 + 依赖声明
├── .octo/
│   └── config.yaml          # 运行时配置(LLM + 日志)
├── config/
│   └── config.go            # 配置加载与校验
├── structs/
│   └── config.go            # 配置结构体定义
├── llm/
│   ├── model.go             # ChatModel 初始化
│   ├── chat.go              # 流式对话接口
│   └── system_prompt.go     # 系统提示词
├── tui/
│   └── tui.go               # Bubbletea 终端界面
└── setup/
   ├── setup.go             # 应用初始化编排
   └── init_log.go          # 分级文件日志系统

设计原则main.go 只有 12 行,不包含任何业务逻辑。所有初始化由 setup 层编排,各模块职责单一、边界清晰。


5. 配置系统:让项目能"换"

5.1 数据结构

配置的骨架定义在 structs/config.go

// structs/config.go

type LLMConfig struct {
    Model   string `yaml:"model"`    // 模型名称,如 deepseek-v4-flash
    BaseURL string `yaml:"base_url"` // API 地址
    APIKey  string `yaml:"api_key"`  // 密钥
    Timeout int    `yaml:"timeout"`  // 超时时间(秒)
}

type OctoConfig struct {
    LLMConfig LLMConfig `yaml:"llm"`
    LogConfig LogConfig `yaml:"log"`
}

type LogConfig struct {
    Level     int    `yaml:"level"`      // 日志级别 1-4
    Format    string `yaml:"format"`     // text 或 json
    LogDir    string `yaml:"log_dir"`    // 日志目录
    AddSource bool   `yaml:"add_source"` // 是否添加源码位置
    Console   bool   `yaml:"console"`    // 是否输出到控制台
}

5.2 加载与校验

config/config.go 负责从 .octo/config.yaml 读取并校验:

// config/config.go

var configPath = ".octo/config.yaml"
var OctoConfig *structs.OctoConfig

func LoadConfig(_ context.Context) error {
    getwd, err := os.Getwd()
    if err != nil {
        return err
    }
    configFilePath := filepath.Join(getwd, configPath)
    file, err := os.ReadFile(configFilePath)
    if err != nil {
        return errors.WithStack(err)
    }

    OctoConfig = &structs.OctoConfig{}
    if err = yaml.Unmarshal(file, OctoConfig); err != nil {
        return errors.WithStack(err)
    }

    return validateConfig(OctoConfig)
}

校验逻辑确保关键字段不为空:

func validateConfig(cfg *structs.OctoConfig) error {
    if cfg.LLMConfig.Model == "" {
        return errors.New("llm model is required")
    }
    if cfg.LLMConfig.BaseURL == "" {
        return errors.New("llm base_url is required")
    }
    if cfg.LLMConfig.APIKey == "" {
        return errors.New("llm api_key is required")
    }
    if cfg.LLMConfig.Timeout <= 0 {
        return errors.New("llm timeout must be positive")
    }
    // ... 日志配置校验
    return nil
}

5.3 配置文件

用户只需编辑 .octo/config.yaml

llm:
  model: 'deepseek-v4-flash'
  base_url: 'https://api.deepseek.com'
  api_key: 'sk-xxx'
  timeout: 300

log:
  level: 1
  format: text
  log_dir: .logs
  add_source: true
  console: false

为什么要这样设计?

把配置从代码中剥离,意味着换模型只改 YAML,不碰 Go 代码。这在智能体开发中至关重要——你很快会发现,同一个 agent 可能需要在 GPT-4o、Claude、DeepSeek 之间来回切换做测试。


6. LLM 集成:让模型"说话"

6.1 为什么选 Eino?

LLM 的 API 调用看似简单(一个 HTTP POST),但生产级实现需要处理:流式响应解析、重试机制、token 计数、Tool Calling 协议等。

我们选用 CloudWeGo Eino 框架,它提供了统一的 ChatModel 接口:

graph LR
    A[你的代码] -->|调用| B[Eino ChatModel]
    B -->|HTTP| C[OpenAI 兼容 API]
    C -->|流式响应| B
    B -->|StreamReader| A

Eino 的好处是:同一套代码,换个 BaseURL 就能对接 DeepSeek、通义千问、Moonshot 等任何 OpenAI 兼容的 API

6.2 模型初始化

// llm/model.go

var tcm model.ToolCallingChatModel

func InitModel(ctx context.Context, llmConfig structs.LLMConfig) error {
    config := &openai.ChatModelConfig{
        APIKey:  llmConfig.APIKey,
        Timeout: time.Second * time.Duration(llmConfig.Timeout),
        BaseURL: llmConfig.BaseURL,
        Model:   llmConfig.Model,
    }
    var err error
    tcm, err = openai.NewChatModel(ctx, config)
    return err
}

ToolCallingChatModel 接口同时支持普通对话和工具调用——虽然当前版本只用到了对话能力,但接口选型已经为未来的 Tool Use 留好了扩展口。

6.3 流式对话

流式输出是终端 AI 助手的体验核心——用户不想等 5 秒看到一大段文字,而是想看到文字像打字一样逐字出现。

// 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)
}

关键设计:

  1. System Prompt 注入:每轮对话都在 history 前面拼接 system prompt,确保模型角色一致
  2. 返回 StreamReader:不直接消费流,而是把流的控制权交给上层(TUI),实现解耦
  3. 历史对话传递:调用方负责维护 historyChatStream 只管发送

6.4 系统提示词

系统提示词定义了 Octo 的"人设":

// llm/system_prompt.go

var octoPrompt = `
你是一个名为 Octo 的个人 AI 助手,运行在命令行终端中。

## 核心定位
- 你是用户的私人助手,帮助解决日常工作、学习和生活中的各种问题
- 你擅长编程开发、代码分析、技术问题解答、写作润色、翻译、数据分析等
- 你可以处理多轮对话,记住上下文并提供连贯的帮助

## 沟通风格
- 使用中文与用户交流(除非用户使用其他语言)
- 回答简洁明了,重点突出,避免冗长
- ...
`

注意:当前版本的 system prompt 是硬编码的。在后续版本中,我们会支持从 YAML 或 Markdown 文件加载,并允许用户自定义。


7. 终端界面:让用户"看见"

7.1 为什么用 Bubbletea?

Go 生态中 TUI 框架不少,但 Bubbletea 是最成熟的一个。它基于 Elm 架构(Model-Update-View),把 UI 状态管理变得像写 Redux 一样清晰:

graph LR
    A[用户输入<br/>KeyPressMsg] --> B[Update<br/>处理消息]
    B --> C[Model<br/>更新状态]
    C --> D[View<br/>渲染界面]
    D --> E[终端输出]
    E -->|等待下一条消息| A

7.2 核心数据结构

// tui/tui.go

type OctoModel struct {
    ctx      context.Context
    config   *structs.OctoConfig
    input    textinput.Model          // 输入框组件
    history  []*schema.Message        // 对话历史
    response string                   // 当前流式响应
    stream   *schema.StreamReader[*schema.Message]  // 当前流
}

7.3 消息处理(Update)

Update 方法是整个 TUI 的大脑,处理两类消息:

func (m *OctoModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyPressMsg:
        switch msg.String() {
        case "ctrl+c":
            // 退出前关闭流
            if m.stream != nil {
                m.stream.Close()
            }
            return m, tea.Quit

        case "enter":
            value := m.input.Value()
            if value == "" {
                return m, nil
            }
            m.clear()
            m.history = append(m.history, schema.UserMessage(value))
            return m, m.startStream()  // 触发异步流式请求
        }

    case streamMsg:
        // 流式数据到达
        if msg.err != nil {
            m.response = "错误: " + msg.err.Error()
            return m, nil
        }
        if msg.isDone {
            // 流结束,将完整回复加入历史
            if m.response != "" {
                m.history = append(m.history,
                    schema.AssistantMessage(m.response, nil))
            }
            return m, nil
        }
        // 累加内容并继续读取
        m.response += msg.content
        return m, m.readStream(m.stream)
    }
    // ...
}

流式读取的核心是一个递归的 tea.Cmd 链:

func (m *OctoModel) startStream() tea.Cmd {
    return func() tea.Msg {
        stream, err := llm.ChatStream(m.ctx, m.history)
        if err != nil {
            return streamMsg{err: err}
        }
        return streamMsg{stream: stream}
    }
}

func (m *OctoModel) readStream(stream *schema.StreamReader[*schema.Message]) tea.Cmd {
    return func() tea.Msg {
        if stream == nil {
            return streamMsg{isDone: true}
        }
        msg, err := stream.Recv()
        if err != nil {
            if err == io.EOF {
                return streamMsg{isDone: true}
            }
            return streamMsg{err: err}
        }
        return streamMsg{content: msg.Content}
    }
}
sequenceDiagram
    participant User as 用户
    participant TUI as Bubbletea
    participant LLM as LLM API

    User->>TUI: 输入消息 + Enter
    TUI->>TUI: history 追加 UserMessage
    TUI->>LLM: ChatStream(history)
    LLM-->>TUI: StreamReader

    loop 流式接收
        TUI->>LLM: stream.Recv()
        LLM-->>TUI: chunk(部分内容)
        TUI->>TUI: response += chunk
        TUI->>User: View() 实时渲染
    end

    LLM-->>TUI: io.EOF
    TUI->>TUI: history 追加 AssistantMessage
    TUI->>User: 最终渲染

7.4 界面渲染(View)

func (m *OctoModel) View() tea.View {
    var sb strings.Builder

    // ASCII Logo
    sb.WriteString(logo)

    // 模型信息
    sb.WriteString("\nModel ")
    sb.WriteString(m.config.LLMConfig.Model)
    sb.WriteString("\n\n")

    // 历史消息
    for _, msg := range m.history {
        switch msg.Role {
        case schema.User:
            sb.WriteString("You: ")
            sb.WriteString(msg.Content)
            sb.WriteString("\n\n")
        case schema.Assistant:
            sb.WriteString("Assistant: ")
            sb.WriteString(msg.Content)
            sb.WriteString("\n\n")
        }
    }

    // 当前流式响应(正在生成中)
    if m.response != "" && m.stream != nil {
        sb.WriteString("Assistant: ")
        sb.WriteString(m.response)
        sb.WriteString("\n\n")
    }

    // 输入框 + 提示
    sb.WriteString(m.input.View())
    sb.WriteString("\n")
    sb.WriteString("(Ctrl+C 退出)\n")

    return tea.NewView(sb.String())
}

8. 日志系统:让问题"可查"

8.1 设计思路

智能体应用的日志量很大——每次 API 调用、每轮对话、每个错误都需要记录。如果全部写到一个文件,排查问题时就像大海捞针。

Octo 的方案是按级别分文件

graph TD
    A[slog 系统调用] --> B{日志级别}
    B -->|Level 1| C[debug.log]
    B -->|Level 2| D[info.log]
    B -->|Level 3| E[warn.log]
    B -->|Level 4| F[error.log]
    E -->|同时写入| F

warn 级别的日志会同时写入 warn.logerror.log,因为警告往往和错误相关。

8.2 实现

核心是一个自定义的 slog.Handler

// setup/init_log.go

type LevelFileHandler struct {
    handlers map[slog.Level]*slog.TextHandler
    files    map[slog.Level]*os.File
    minLevel slog.Level
}

初始化时为每个级别创建独立的文件 handler:

func NewLevelFileHandler(dir string, opts *slog.HandlerOptions) (*LevelFileHandler, error) {
    os.MkdirAll(dir, 0755)

    levels := map[slog.Level]string{
        slog.LevelDebug: "debug.log",
        slog.LevelInfo:  "info.log",
        slog.LevelWarn:  "warn.log",
        slog.LevelError: "error.log",
    }

    handlers := make(map[slog.Level]*slog.TextHandler)
    files := make(map[slog.Level]*os.File)
    for level, filename := range levels {
        f, _ := os.OpenFile(
            filepath.Join(dir, filename),
            os.O_CREATE|os.O_WRONLY|os.O_APPEND,
            0644,
        )
        handlers[level] = slog.NewTextHandler(f, opts)
        files[level] = f
    }
    // ...
}

日志路由逻辑:

func (h *LevelFileHandler) Handle(_ context.Context, record slog.Record) error {
    // 写入对应级别文件
    if handler, ok := h.handlers[record.Level]; ok {
        handler.Handle(context.Background(), record)
    }
    // warn 同时写入 error 文件
    if record.Level == slog.LevelWarn {
        if handler, ok := h.handlers[slog.LevelError]; ok {
            handler.Handle(context.Background(), record)
        }
    }
    return nil
}

8.3 多输出支持

日志同时支持控制台和文件双输出,通过 slog.NewMultiHandler 组合:

func InitLog(cfg *structs.LogConfig) error {
    var handlers []slog.Handler

    if cfg.Console {
        // 控制台输出
        handlers = append(handlers, slog.NewTextHandler(os.Stdout, opts))
    }
    if cfg.LogDir != "" {
        // 分级文件输出
        fileHandler, _ := NewLevelFileHandler(cfg.LogDir, opts)
        handlers = append(handlers, fileHandler)
    }

    slog.SetDefault(slog.New(slog.NewMultiHandler(handlers...)))
    return nil
}

9. 应用编排:把一切串起来

9.1 入口

main.go 极度简洁——只有 12 行:

func main() {
    ctx := context.Background()
    setup.Init(ctx)
}

9.2 编排层

setup.go 按依赖顺序依次初始化:

func initInner(ctx context.Context) error {
    // 1. 加载配置(后续模块都依赖它)
    if err := config.LoadConfig(ctx); err != nil {
        return err
    }
    // 2. 初始化日志(后续模块需要日志能力)
    if err := InitLog(&config.OctoConfig.LogConfig); err != nil {
        return err
    }
    // 3. 初始化 LLM(TUI 依赖它做对话)
    if err := llm.InitModel(ctx, config.OctoConfig.LLMConfig); err != nil {
        return err
    }
    // 4. 启动 TUI(阻塞运行,直到用户退出)
    if err := tui.Init(ctx, config.OctoConfig); err != nil {
        return err
    }
    return nil
}

初始化顺序不是随意的——它遵循依赖链

graph LR
    C[1. Config<br/>配置] --> L[2. Log<br/>日志]
    L --> M[3. Model<br/>LLM]
    M --> T[4. TUI<br/>界面]

任何一步失败,程序直接 panic 退出并打印错误。对于 CLI 工具来说,这是合理的——静默失败比崩溃更危险。


10. 依赖关系全景

graph TB
    subgraph 内部模块
        main[main]
        setup[setup]
        config[config]
        structs[structs]
        llm[llm]
        tui[tui]
    end

    subgraph 外部依赖
        eino[cloudwego/eino<br/>LLM 框架]
        einoOpenai[eino-ext/openai<br/>OpenAI 适配器]
        bubbletea[charmbracelet/bubbletea<br/>TUI 框架]
        bubbles[charmbracelet/bubbles<br/>UI 组件]
        yaml[gopkg.in/yaml.v3]
        errors[pkg/errors]
    end

    main --> setup
    setup --> config
    setup --> llm
    setup --> tui
    config --> structs
    llm --> structs
    tui --> structs
    tui --> llm

    llm --> eino
    llm --> einoOpenai
    tui --> bubbletea
    tui --> bubbles
    config --> yaml
    config --> errors
    llm --> errors

11. 当前版本的局限与思考

作为系列文章的第一章,Octo v0.1.0 刻意保持简单。但简单不等于简陋——每个设计决策都有其理由,也为后续迭代留下了空间:

当前状态局限未来方向
System Prompt 硬编码无法自定义角色支持从文件加载 + 动态切换
纯文本对话无 Tool Calling接入工具调用能力
单文件日志无日志轮转引入 lumberjack 做日志切割
无持久化重启丢失对话支持 SQLite/文件存储历史
无错误重试网络抖动直接报错指数退避重试机制
handler/ 为空无消息处理管线支持 middleware 链式处理
无版本管理不知道运行的哪个版本编译时注入版本号

12. 运行效果

启动 Octo 后,你会看到:

  ██████╗   ██████╗ ████████╗  ██████╗
 ██╔═══██╗ ██╔════╝ ╚══██╔══╝ ██╔═══██╗
 ██║   ██║ ██║         ██║    ██║   ██║
 ██║   ██║ ██║         ██║    ██║   ██║
 ╚██████╔╝ ╚██████╗    ██║    ╚██████╔╝
  ╚═════╝   ╚═════╝    ╚═╝     ╚═════╝

Model deepseek-v4-flash

You: 你好,请介绍一下自己

Assistant: 你好!我是 Octo,一个运行在终端里的 AI 助手...

(Ctrl+C 退出)

13. 本章小结

通过 Octo v0.1.0,我们搭建了一个工程化的 LLM 终端助手骨架:

mindmap
  root((Octo v0.1.0))
    配置管理
      YAML 解析
      结构体校验
      运行时热切换
    LLM 集成
      Eino 框架
      OpenAI 兼容
      流式输出
    终端界面
      Bubbletea
      Elm 架构
      多轮对话
    日志系统
      slog 原生
      分级分文件
      多输出聚合
    应用编排
      依赖链初始化
      单一入口
      快速失败

核心收获

  1. 配置与代码分离——换模型不改代码
  2. 流式输出——用户体验的关键
  3. 模块边界清晰——每个包做一件事
  4. 日志分级——出问题能查到
  5. 接口预留——ToolCallingChatModel 为 Tool Use 留好扩展口

项目源码:github.com/aethelgards…