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
| 维度 | Go | Python |
|---|---|---|
| 部署形态 | 单二进制,零依赖 | 需要 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 |
| langchaingo | LangChain 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 其他方案
| 框架 | 架构模型 | 组件生态 | 社区活跃度 | 适合场景 |
|---|---|---|---|---|
| Bubbletea | Elm (MVU) | bubbles + lipgloss | 极高(Charm 全家桶) | 复杂交互式 TUI |
| tview | 直接式(构建后运行) | 丰富(表格、树、模态框) | 中等 | 管理后台、仪表盘 |
| termui | Widget 堆叠 | 基础 | 低 | 简单状态展示 |
| 自绘 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 成熟 | ✅ | ✅ | 中等 |
| TOML | 高 | toml.v2 可用 | ✅ | 一般 | 小 |
| JSON | 中等 | 标准库 | ❌ | ✅ | 大 |
| ENV | 低 | os.Getenv | ❌ | ❌ | 小 |
选择 YAML 的理由:
Octo 的配置需要支持嵌套结构(llm.model、log.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 | 零依赖 |
|---|---|---|---|---|---|
| slog | Go 标准库 | 高 | ✅ | ✅ | ✅ |
| zap | Uber | 极高 | ✅ | 一般 | ❌ |
| zerolog | RS | 极高 | ✅ | 一般 | ❌ |
| logrus | Sirupsen | 中 | ✅ | 一般 | ❌ |
选择 slog 的理由:
Octo 是一个 CLI 工具,不是高并发微服务。日志量级不需要 zap/zerolog 的极致性能优化。而 slog 作为 Go 1.21+ 的标准库,有几个关键优势:
- 零外部依赖——不引入额外的
go.mod条目 - Handler 接口开放——Octo 的
LevelFileHandler就是基于slog.Handler接口自定义的 - MultiHandler 支持——一行代码实现控制台 + 文件双输出
- 与 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)
}
关键设计:
- System Prompt 注入:每轮对话都在
history前面拼接 system prompt,确保模型角色一致 - 返回 StreamReader:不直接消费流,而是把流的控制权交给上层(TUI),实现解耦
- 历史对话传递:调用方负责维护
history,ChatStream只管发送
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.log 和 error.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 原生
分级分文件
多输出聚合
应用编排
依赖链初始化
单一入口
快速失败
核心收获:
- 配置与代码分离——换模型不改代码
- 流式输出——用户体验的关键
- 模块边界清晰——每个包做一件事
- 日志分级——出问题能查到
- 接口预留——
ToolCallingChatModel为 Tool Use 留好扩展口