我大概花了三个晚上加一个完整周末,零零散散加起来二十多个小时,用Go写了一个命令行版本的AI聊天客户端。起因很朴素:想在不打开浏览器、不登录各种网页界面的情况下,直接在终端里跟大模型聊几句,顺便还能把它嵌进自己的shell工作流里。一开始我以为这活儿半天就能搞定,毕竟只是发个HTTP请求然后打印字符串。真做起来才发现,从流式输出到上下文管理,再到跨平台终端兼容,每一步都有选择要做,每一步都在权衡“省时间”和“挖坑”这对矛盾。这篇文章就是我的完整复盘,包括设计思路、核心代码、踩过的坑,以及我个人对这个问题的最终判断。
1. 为什么我会用Go写命令行AI客户端
1.1 原始需求:我真的需要一个终端里的AI助手吗
动手之前,先想清楚一个很基础的问题:我到底缺一个什么样的工具?那会儿我在维护一个偏底层的Go服务,每天大概有三分之一的时间泡在SSH里,剩下时间在本地终端里查日志、改配置、跑测试。遇到一个不太认识的结构化日志或者编译报错,我都会下意识地先在终端里复制报错片段,然后切到浏览器搜索。这个过程其实非常打断思路,窗口一多起来,经常会出现复制了但还没粘贴,就已经忘记本来想问什么的情况。
我需要的是一个能原地对话的助手,它可以接收我粘贴的报错信息,能记住我们刚才聊到哪了,也允许我用管道把命令输出直接传给它。最理想的形态就是一个小命令,像ai ask “这个报错是什么意思”这样,把上下文文件和报错内容一起塞进去,立刻得到建议。说白了,就是希望命令行本身能跟大模型交互,而不是去适应一个网页。
1.2 选Go的几个硬理由
确定需求之后,选型是第一个岔路。其实开发一个API客户端,可选的语言很多,Python、Node、Rust都能做。我自己最终选了Go,原因挺实在。
第一,部署和分发非常简单。Go编译出来就是一个静态二进制,几乎没有运行时依赖。我可以把编译产物直接放到服务器上,也可以交叉编译出Windows版本扔给同事,不需要帮他们装Python环境,也不会遇到依赖冲突的问题。对于命令行工具来说,这个体验非常值钱。
第二,启动速度快。Python的CLI工具启动要加载解释器,虽然一般也就0.1到0.2秒,但每次敲命令都等一下,长期用下来很难受。Go编译的二进制启动几乎是毫秒级的,在终端这种高频操作场景里,体感差别很明显。
第三,并发模型天然适合处理流式响应。大模型接口的流式输出,本质上是持续不断地从网络连接里读取数据块,再逐段展示给用户。Go的goroutine加channel,处理这种“读一段、输出一段、随时可能提前退出”的模式非常顺手。我用其他语言可能也能写,但写起来没有这么清晰。
当然,Python的开发速度确实快,写个脚本可能半小时就够了。但考虑到后面要维护、要加功能、要保证在多种终端下稳定运行,我最终还是站在了Go这边。
1.3 范围控制:先做一个能用的MVP
技术选型定了,下一个问题就是“做什么”。如果一上来就想要一个包含对话历史列表、Markdown渲染、代码高亮、多用户登录的“全家桶”,那这项目画个UI都能画一个月。我给自己定的MVP范围非常简单:
- 支持一条命令直接提问,比如
ai ask “...”。 - 支持交互式聊天模式,能连续对话并保留上下文。
- 支持通过管道读取标准输入,把文件内容或命令输出喂给模型。
- 支持多模型切换和常见参数覆盖,比如temperature和max_tokens。
- 配置简单,不要求用户理解插件、LSP之类的东西。
- 错误处理最少要做到“不黑屏、不卡死、给出明确提示”。
至于什么TUI彩色界面、插件系统、本地知识库,我全都没放进第一版。事实证明这个范围控制至关重要。因为一旦做了过多复杂界面,后期调试的时间就会成倍上涨。
2. 核心功能拆解与关键技术点
2.1 命令行框架:flag够用,但cobra更适合扩展
命令行客户端首先得有个命令行解析框架。Go标准库的flag包确实能写,但做出来的体验比较粗糙。它不支持子命令结构,比如你没法优雅地实现ai ask和ai chat分别携带不同参数。如果你只有一个功能,flag就够了,但我的项目至少有ask、chat、config这几个子命令。所以我用了cobra,它是Go社区里最主流的CLI框架,很多知名工具比如kubectl、hugo都是用它写的。
使用cobra并不复杂,一个简单的初始化大概是这样的:
var rootCmd = &cobra.Command{ Use: "ai", Short: "AI assistant for the command line", } var askCmd = &cobra.Command{ Use: "ask", Short: "Ask a one-off question", Run: func(cmd *cobra.Command, args []string) { // 进入单次提问逻辑 }, } func init() { rootCmd.AddCommand(askCmd) // 全局flag定义在这里 }cobra带来的最大好处是命令的扩展性和帮助信息的自动生成。不过也有个坑,cobra的flag作用域有时候会让人困惑。比如在父命令上定义的flag,子命令里需要PersistentFlags()才能继承;如果你用了Flags(),那这个flag只在定义它的命令上生效。我第一次写的时候就因为没分清这两个方法,导致--model参数在ask命令里一直不起作用。排查了半天,最后才发现是flag作用域的问题。
2.2 配置与密钥管理:别把API Key留在代码里
CLI工具虽然是小工具,但安全习惯不能掉线。API Key这种敏感信息,如果直接写在源码里,或者放在一个提交到Git的配置文件里,等于把钥匙挂在门口。我是按下面的优先级来读取配置的:命令行参数 > 环境变量 > 用户配置文件 > 默认值。
用户配置文件推荐放在~/.config/ai-cli/config.yaml,内容大致是这样的:
api_base: "https://api.example.com/v1" api_key: "${AI_API_KEY}" # 或者直接填,但记得chmod 600 model: "gpt-4o-mini" temperature: 0.7 max_tokens: 2048这里有两个小建议。第一,如果config里直接写了API Key,那这个文件的权限至少要是0600,避免同机其他用户读取到。很多新同学会忽略这一点,默认umask下新建文件可能是0644,等于对系统里所有用户开放了。第二,可以在配置文件里用“环境变量占位符”,解析时再替换成真实值。这样既方便本机调试,也可以避免把明文Key写进文件。
2.3 流式输出:解析SSE流比想象中要敏感
大模型接口现在基本都支持流式返回,也就是通过SSE(Server-Sent Events)协议把回答内容一段一段推送过来。如果不使用流式接口,那用户只能盯着空白终端等上几秒甚至几十秒,体验非常差。所以在设计API Client时,请求体里必须设置“stream”: true,然后解析返回的text/event-stream数据流。
SSE的响应体长这样:
data: {"id":"...","choices":[{"delta":{"content":"你好"},"index":0}]} data: {"id":"...","choices":[{"delta":{"content":"世界"},"index":0}]} data: [DONE]每一段事件以空行分隔,数据行以data:开头。在Go里处理这个数据流,一个比较稳的做法是用bufio.Reader逐行读取,而不是一次性io.ReadAll,因为我们希望一边读取一边输出。解析的关键点有两个:一是要处理一行数据可能被网络包拆成多次Read的情况,二是要能跳过空行和注释行。
我踩过的具体坑会在后文展开,这里先说结论:不要用bufio.Scanner默认的64KB token上限去读超长行,需要手动调大缓冲区。也不要指望每段SSE都对应一个完整JSON,网络波动时一个JSON对象被拆成两段是很常见的。
2.4 对话上下文:如何限制token,避免悄悄超预算
支持聊天的客户端离不开上下文管理。大模型本身是无状态的,每轮对话都要把之前所有的消息重新发给它。如果聊着聊着把history越攒越长,你会看到两个问题:第一个是费用越来越高,第二个是可能直接触发最大上下文限制,请求直接报错。
我最初的做法很简单,就是保存全部消息。后来发现聊到三十轮左右,请求体就会变得非常大,不仅API响应变慢,费用也肉眼可见地增加。后来我改成滑动窗口策略:保留系统提示词,然后从历史消息里由近到远保留最近N个turn,保证总token数不超过一个设定阈值,比如4000。实际的token计数通常会用到tiktoken库或者API返回的usage字段,不过对于普通场景,可以先按“字符数除以3”粗略估算token数,一样够用。
这个策略的代价是,如果隔得太久,模型会忘掉前面的一些内容。但相比让对话彻底卡死,适当地遗忘明显是更合理的方案。
3. 实战:从零实现一个可用的CLI客户端
3.1 项目骨架搭建与最小依赖选择
下面进入实战。一个合理的项目结构大致是这样:
ai-cli/ ├── main.go ├── cmd/ │ ├── root.go │ ├── ask.go │ └── chat.go ├── internal/ │ ├── config/ │ │ └── config.go │ ├── api/ │ │ └── client.go │ └── chat/ │ └── conversation.go └── go.mod这个结构借鉴了Go社区常见的分层方式:cmd放命令入口,internal放业务逻辑。依赖方面我最终只用了两个外部包:spf13/cobra和gopkg.in/yaml.v3。可能有人会问,怎么不用其他的TUI库?因为第一版我打算用最简单的“扫描标准输入+打印输出”来做聊天界面,尽量避免引入Bubble Tea这类完整TUI框架。说实话,如果一开始就引入它,那要处理的事情会多出一大截。
3.2 ask命令:从请求到流式打印的关键实现
ask命令是最基本的单次问答入口。它要做的事情可以拆成三步:读配置、发请求、流式打印。
核心的HTTP请求部分的代码大概是:
func StreamChat(client *http.Client, req ChatRequest) (*http.Response, error) { body, _ := json.Marshal(req) httpReq, _ := http.NewRequest("POST", req.URL, bytes.NewReader(body)) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+req.APIKey) resp, err := client.Do(httpReq) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("unexpected status: %s", resp.Status) } return resp, nil }拿到resp.Body之后,就不能用io.ReadAll了,要用bufio.Reader逐行扫描:
reader := bufio.NewReader(resp.Body) for { line, err := reader.ReadBytes('\n') if err != nil { if err == io.EOF { break } return err } line = bytes.TrimSpace(line) if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) { continue } data := bytes.TrimSpace(line[5:]) if string(data) == "[DONE]" { break } var payload struct { Choices []struct { Delta struct { Content string `json:"content"` } `json:"delta"` } `json:"choices"` } if err := json.Unmarshal(data, &payload); err != nil { continue } if len(payload.Choices) > 0 { fmt.Print(payload.Choices[0].Delta.Content) } }这个循环看起来简单,但有几个细节很容易翻车。比如json.Unmarshal之前一定要判断数据前缀,否则把其他行也喂给JSON解析会浪费性能甚至报错。另外,有些GPT风格的接口会在每条data后面跟一个空行,所以TrimSpace和跳过空行的逻辑必不可少。最后,注意fmt.Print不要用fmt.Println,否则每打印一个增量都会多一个换行,输出完全没法看。
3.3 chat模式:连续对话与历史记录
chat模式比ask多了一个关键点:要维护消息历史,并且让用户能在一个循环里连续输入。我实现的方式非常朴素:
for { fmt.Print("\n> ") input, _ := reader.ReadString('\n') input = strings.TrimSpace(input) if input == "exit" || input == "/quit" { break } history = append(history, Message{Role: "user", Content: input}, Message{Role: "assistant", Content: ""}) // 渲染loading提示 // 发送请求,增量写入最后一条assistant消息 }这里有一个我很容易说漏但其实很重要的点:在发送请求之前,就要往history里追加一个空的assistant消息占位,然后在流式打印的时候,一边打印一边把内容写进这个占位消息。不然等流式结束后,history里就缺了模型这轮的回复,下一轮再发时,上下文就少了一段。这个小坑让我抓了半天头。
历史记录的持久化我放在了~/.local/share/ai-cli/sessions/下,每个会话一个JSON文件,退出后可以重新载入。这个功能其实不是MVP必须的,但一旦用上了,就会觉得每天问过的问题都有记录,方便回看。
3.4 超时、重试与错误码:别让用户干等
命令行工具最忌讳的就是用户发出请求后,终端像冻结了一样。所以要处理两类问题:超时和重试。
超时这里有个容易犯的错误:http.Client{Timeout: 30 * time.Second}是所有请求共用整体超时的,但它对流式连接不友好。流式响应可能运行超过30秒,超过后读一半就断了。更好的做法是不给Client设置全局Timeout,而是在每次请求时通过context.WithTimeout设置一个更长的上限,比如5分钟,并且允许用户在配置里调整。或者干脆依赖服务器端的流式心跳机制,超过一定间隔没有数据时再主动判断超时。
重试策略也需要谨慎。官方API的429限流错误和5xx服务端错误可以安全重试;但400请求格式错误和401鉴权失败,重试再多次都是白费,反而会让用户更恼火。我的实现里只对429和500、502、503做了指数退避重试,默认最多3次。重试之间打印一行提示“Retrying in 2s...”,至少让用户知道程序还活着。
4. 踩过的坑与排查记录
4.1 半个事件:SSE流的边界到底该怎么切
我前面提到过,网络数据流到达本地时,不一定按服务端发送的事件边界来切分。第一次做的时候我把整块响应按s strings.Split(body, "\n\n")来切事件,结果在高延迟或弱网环境下一段事件被截断,解析出来的JSON就是不完整的,程序直接跳过了一段内容。后来我改用bufio.Reader.ReadBytes('\n')逐行读取,再在逻辑层面把以data:开头的行拼接成完整事件。遇到不完整JSON时不要急着报错,存进一个pendingBuffer,等下一段数据到达后继续拼接。这个处理是流式客户端必须过的考验。
4.2 Markdown渲染:在终端里什么时候该高亮
大模型默认喜欢输出Markdown,标题里的#号、列表里的*号、代码块里的反引号,如果直接原样打印,终端里简直没法看。第一次做的时候我选择直接用正则剥掉所有Markdown符号,结果代码块里的注释符号也被误删了,很尴尬。后来我研究了一下Go生态里的glamour库,它可以把Markdown渲染成带ANSI颜色和样式的终端文本,效果很好。
但引入渲染后还有个新问题:如果用户把命令输出重定向到文件或者管道里,比如ai ask “...” > output.txt,ANSI颜色代码会被写进文件,看起来全是乱码。解决方案是判断标准输出是否为TTY:os.Stdout.Stat()的ModeCharDevice。只有TTY时才启用渲染,否则输出纯文本。这也算是一个企业级工具的常用细节。
4.3 Windows终端上的ANSI颜色和编码
我的日常开发在Mac上,但交叉编译后丢给Windows同事跑,反馈说输出里出现了奇怪的字符。一查发现是Windows控制台默认不启用ANSI转义序列。Go程序要输出彩色文本,在Windows上需要调用golang.org/x/sys/windows里的接口启用虚拟终端处理。
后来我图省事,直接用了一个跨平台包github.com/mattn/go-colorable来替代标准输出,问题就干净地解决了。如果你的工具面向多平台,从第一天就考虑这个兼容性是值得的。另外,Windows的cmd和PowerShell对换行符的渲染也有细微差异,我实测下来Windows Terminal体验最好,老式cmd建议直接不要支持彩色输出,或者使用纯文本模式。
4.4 并发写历史文件:一个微不足道但真实的Bug
我在保存聊天记录时用的是一个JSON文件,一开始是每次更新对话就os.WriteFile全量覆盖。后来发现当我连续快速输入两轮时,文件里偶尔会丢数据。原因其实很简单:两个goroutine或者连续两次写操作没有锁保护,后者把前者的内容覆盖了。修复方式也很简单:用一个sync.Mutex保护内存中的会话状态,写文件时先写入临时文件再os.Rename原子替换。这个问题不复杂,但很能说明,即便是单用户工具,也要有最基本的并发安全意识。
4.5 依赖库的版本陷阱:锁定go.mod但不能锁坑
项目里我一开始引了一个第三方YAML库,后来发现它的一个子版本在输出特殊字符串时会有转义Bug。这类问题排查起来非常费劲,因为错误不会立刻暴露,只在特定内容时才触发。后来我把YAML解析改成了标准库里稳定一些的方案,或者直接挑选维护活跃、star数足够高的库。受这个教训影响,我现在给CLI工具选依赖的基本标准是:功能尽量简单、维护频率高、API稳定、不随便引入重依赖。一句话,库越少,坑越少。
4.6 测试的尴尬:流式响应怎么Mock
单元测试这件事,我一开始完全没写,因为觉得CLI工具手动跑就行。后来要改一个解析逻辑,每次都要开着网络请求去调真实API,既慢又费钱,而且网络一抖动就会跳出无关的失败。被折磨了几次之后,我最终给流式解析器写了一个helper,接收io.Reader作为输入,这样测试时可以传入字符串构造的reader,不需要真正发HTTP请求。API Client层的测试则用一个http.RoundTripper的mock实现,返回写死的SSE流。测试补上后,改动逻辑明显舒服了很多。虽然花了一些额外时间,但这是第一个值得提前做的“省时间”投资。
4.7 高频问题与快速解决速查表
最后把我在实际使用中碰到的高频问题整理成一张速查表,方便你快速定位:
| 问题现象 | 可能原因 | 快速解决 |
|---|---|---|
| 终端输出乱码或颜色代码 | 非TTY管道输出/Windows未启用ANSI | 判断ModeCharDevice,输出纯文本 |
| API Key失效 | 环境变量未设置或配置被覆盖 | 按命令行参数>环境变量>配置文件排查 |
| 响应中途断流 | 网络不稳或超时设置太短 | 使用流式上下文超时,增加重试 |
| JSON解析失败 | 没有拼接半个事件 | 改用bufio逐行读+pendingBuffer |
| 上下文过长导致报错 | history无限增长 | 实现token滑动窗口 |
| 文件记录丢失 | 并发写同一文件 | 加锁+临时文件rename |
这些都是非常典型的CLI开发问题,记录下来至少能让再遇到同样问题的同学少走弯路。
5. 省时间还是挖坑:结论与建议
5.1 我的开发时间和体感数据
做完全部核心功能,我大致花了3个晚上加一个完整周末,大概20多个小时。MVP阶段在第一个晚上就跑通了,后面花时间最多的地方分别是chat模式的状态管理、Markdown渲染的TTY判断,以及Windows兼容。整体来说,开发过程中确实有段时间觉得“这坑怎么一个接一个”,但都处在可控范围内,没有出现需要推翻重来的情况。
再算算使用收益。以前我查日志里的一个报错,从复制到切浏览器再到找到合适答案,差不多要三到五分钟。现在直接在终端里把报错喂给AI,基本在20秒内就能得到一个可用解释,遇到特别典型的问题甚至更快。一天按十次查询算,我每天至少能省半个小时。如果按月算,这和开发20多个小时的成本差不多一两个月就回本了,所以无论怎么算,这都不算亏。
5.2 哪些人适合自己写,哪些人直接抄作业就行
这个结论必须分开说。
如果你是第一次写Go,或者对终端交互不太熟悉,我不建议完全从零开发一个功能完整的AI客户端。建议直接去用一些现成的开源工具,比如GitHub上常见的shell-gpt或者mods,都是命令行大模型助手的优秀实现。它们通常已经解决了流式输出、历史记忆、Markdown渲染的问题,你要做的就是克隆下来体验一下。
如果你本身就熟悉Go,有定制化需求,比如要接企业内部网关、要集成自有知识库、要跟某些CI工具深度联动,那自研是有价值的。一个小型CLI客户端是学习Go网络编程、并发模型和命令行框架的绝佳练手项目,就算最后功能没做到极致,过程中积累的工程经验也远超投入的时间。
5.3 如果继续演进,下一步我会做什么
当前版本足够我用,但我心里也有一个演进清单,留给以后有空再说。
第一是支持多会话管理,像tmux一样能同时开多个对话窗口。第二是接入本地嵌入模型,让我可以把项目里的文档切片后用向量检索,实现语义搜索。第三是做一个更友好的交互界面,比如自动换行、跨行输入、粘贴文本检测等。如果真做到那一步,我估计会引入Bubble Tea框架,并好好设计一下组件树。
不过我的忠告是,这些功能每一个都不小,单拎出来都可能带来数天的额外开发量。所以现阶段我选择保持克制,能用外部工具配合就绝不自研,这也是最省时间的策略。
5.4 一点个人心得:先跑通再优化,先能用再好看
从实际角度来看,这个项目的结论是:用Go写命令行AI客户端,只要范围控制得好,确实能省很多时间;但如果你一上来就想做到商业级完整度,那基本是挖坑。我的经验是永远把一个可以运行的版本先做出来,哪怕它只有一个最简单的ask命令,然后再逐步迭代。第一个版本丑没关系,能用就好。等真正进入日常使用了,你自然会更清楚哪些功能值得付出精力去完善。