go-chat-api:从 0 到 1 接入真实 LLM 接口的实践记录
这两天我做了一个小项目:go-chat-api。
它的目标不复杂:用 Go 写一个最小可用的聊天接口,然后把它真正接到大模型 API 上,而不是停留在“本地假数据返回”的阶段。
最终跑通之后,接口已经可以返回真实的模型内容:
1 | { |
这个结果本身并不惊艳,但它背后代表一条完整链路已经打通:HTTP 服务、参数处理、配置读取、分层设计、第三方 API 调用、错误排查,最后到真实结果返回。
这篇文章就是对这次实践的复盘,重点放在链路本身,而不是炫技。
一、为什么要做这个项目
学习技术最容易卡住的地方,不是某个语法不会,而是零散知识点没有串成完整链路。
这次我的目标很明确:先做一个最小闭环,而不是一上来就追求完整。
最小闭环就是这几件事:
- 服务能启动
/ping能检查健康状态/chat能接收 JSON 请求- 能做基础参数校验
- 能读取配置
- 能真实调用 LLM API 并返回结果
先不展开多轮上下文、流式输出、鉴权、数据库这些内容,避免把项目做散。
二、第一步:先把服务跑起来
第一步很简单:先启动一个最基础的 HTTP 服务,再加一个健康检查接口 /ping。
代码大概类似这样:
1 | http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { |
这个接口看起来简单,但它很重要:
- 先确认服务本身能不能起来
- 方便区分“服务没启动”还是“业务接口有问题”
- 后续部署时也能直接当健康检查接口
它就是整条链路里最早的“确认点”。
三、第二步:先做一个最小 /chat
接下来开始做核心接口 /chat。
不过这里我没有一上来就接模型,而是先做了一个“假回复版本”。
目标很简单:
- 只接受
POST - 接收一个 JSON 请求体
- 从中读取
message - 返回一个结构明确的 JSON 响应
请求格式:
1 | { |
最初的返回内容类似这样:
1 | { |
这一步的意义是:
先把 HTTP 层走通,再考虑业务层和第三方调用。
这样做有两个好处:
1. 问题更容易定位
如果一开始就把模型调用、HTTP 请求、配置文件、鉴权头全部混在一起,出了问题几乎很难第一时间判断到底是哪一层错了。
而先做假回复,可以先确认:
- 路由没问题
- JSON 解析没问题
- handler 响应没问题
2. 开发节奏更稳定
先让接口“能工作”,再让接口“变聪明”。
这次实践最明显的一个感受就是:小步快跑,比大步空转更有效。
四、第三步:做分层,而不是把逻辑全写进 main.go
一开始项目很小的时候,很多人都会把逻辑直接堆在 main.go 里,这很正常。
但当 /chat 开始包含更多逻辑后,如果还不拆分,代码会很快变得难以维护。
所以我后面把结构整理成了这样:
1 | internal/ |
这三层各自职责很清晰。
handler 层
负责 HTTP 相关工作:
- 接收请求
- 判断请求方法
- 解析 JSON
- 返回响应
- 调用 service
service 层
负责业务逻辑:
- 组装模型请求
- 调用第三方 API
- 处理响应结果
- 返回最终 reply
model 层
负责定义结构体:
ChatRequestChatResponseErrorResponse- LLM 请求体 / 响应体结构
这种拆法的好处很直接:
handler 不关心模型接口怎么调,service 不关心 HTTP 怎么收发,model 只负责数据结构。
哪怕当前项目还很小,这种分层也已经值回票价了。因为一旦后面要继续加功能,比如上下文、日志、统一返回结构,都会轻松很多。
五、第四步:补齐基础校验和错误处理
接口一旦开始接收外部输入,最先该做的不是“功能增强”,而是“保护边界”。
我先补了两个最基础的校验。
1. 限制请求方法
1 | if r.Method != http.MethodPost { |
2. 检查 message 是否为空
1 | if req.Message == "" { |
这类代码看上去不“高级”,但非常重要。
因为接口稳定性,往往不是体现在“功能多强”,而是体现在:
- 非法请求能不能及时拦住
- 错误信息是否明确
- 是否避免无意义的后续调用
尤其在接第三方服务前,先把输入层收紧,可以减少很多无效排错。
六、第五步:把配置从代码里抽出来
在最初版本里,服务端口通常会直接写死:
1 | port := ":8088" |
这种写法在本地测试阶段没问题,但只要项目稍微正规一点,就不应该继续把这些参数硬编码在代码里。
所以后面引入了 config.yaml:
1 | server: |
对应的 Go 配置结构大概是这样:
1 | type Config struct { |
读取配置的逻辑也相对简单:
1 | func LoadConfig(path string) (*Config, error) { |
随后在 main.go 中读取配置并使用:
1 | cfg, err := config.LoadConfig("config.yaml") |
这一步虽然只是把端口抽到配置文件,但它是一个关键转折点:
项目从“代码练习”变成了“具备配置能力的小服务”。
七、第六步:扩展 LLM 配置
当 /chat 准备接真实大模型时,光有端口配置当然不够。
于是 config.yaml 被继续扩展:
1 | server: |
对应结构体也变成:
1 | type Config struct { |
这样做有两个直接收益:
1. 模型相关参数和业务代码解耦
后续无论是换模型、换平台、换域名,都不需要改核心逻辑。
2. service 层可以直接拿到所需配置
比如:
cfg.LLM.APIKeycfg.LLM.BaseURLcfg.LLM.Model
这为后面真实调用 API 打下了基础。
八、第七步:在 service 层发起真实 LLM 请求
这一步是整个项目里最核心的一步。
因为一旦从“假回复”切换到“真实模型调用”,就意味着你的项目正式开始和外部服务发生交互。
在实现上,主要流程是:
- 读取配置
- 构造请求体
- 将请求体转成 JSON
- 创建 HTTP 请求
- 设置请求头
- 发送请求
- 读取响应体
- 解析模型返回内容
请求结构大概如下:
1 | type ChatMessage struct { |
然后把用户消息组装成请求体:
1 | requestBody := ChatRequestBody{ |
再编码 JSON、构造请求并设置鉴权头:
1 | jsonData, err := json.Marshal(requestBody) |
最后发送请求:
1 | client := &http.Client{} |
我的体会很简单:
一旦需要带鉴权头、自定义 JSON 和更多可控项,就该优先用
http.NewRequest,而不是http.Post。
九、一次很典型的报错:invalid character '<' looking for beginning of value
在真实联调阶段,我遇到了一个很典型、也很值得记录的报错:
1 | { |
这个错误一开始看像是 JSON 解析失败,但真正关键的不是“解析失败”这四个字,而是里面的这个字符:
1 | < |
只要看到这个符号,经验上就该马上联想到:
- 返回值可能不是 JSON
- 很可能是 HTML 页面
- 请求地址大概率有问题
也就是说,程序原本以为自己会收到:
1 | {"choices": ...} |
但实际上拿到的可能是:
1 | <html> |
这类问题通常对应下面几种情况:
base_url填成了网页地址- 请求打到了平台官网而不是 API 域名
- 走错了接口路径
- 网关返回了 HTML 错误页
所以最关键的排查动作就是:
不要只盯着
Unmarshal,先看原始响应到底长什么样。
resp.StatusCode 和 string(bodyBytes) 通常一看就有答案。
十、最终跑通:返回真实模型内容
在修正配置之后,/chat 终于顺利返回了真实内容:
1 | { |
到这里,项目的最小闭环已经完整成立:
- 服务可启动
/ping正常/chat路由正常- 仅允许
POST - JSON 请求可解析
- 参数校验可生效
- 配置文件读取正常
- LLM 参数配置正常
- service 层可成功请求第三方模型
- 接口能返回真实 AI 回复
这一步带来的价值,不只是“接口能用了”,而是项目已经具备继续扩展的基础。
十一、这次实践里最有价值的几点体会
回头看,这次代码量不多,但串起了一条完整链路,几个体会最值得记住:
1. 先做最小闭环
先让服务起来,再让接口可用,再让结构清晰,最后接真实模型。小步快跑,比大步空转更有效。
2. 分层设计早做就值
哪怕项目很小,只要包含 HTTP 和第三方调用,handler / service / model 这种拆分就值得。
3. 配置管理是工程化的起点
端口、模型地址、API Key 这些东西,不应该写死在代码里。把它们抽出来,项目立刻更像真正的服务。
4. 出错先看原始输入和输出
调第三方接口时,很多问题不是逻辑错了,而是地址、Header 或响应格式不对。先看请求和原始响应,通常最快。
十二、接下来还可以继续做什么
接下来还有几个自然的后续方向:
- 统一响应结构
- 支持多轮上下文
- 增加超时控制
- 加日志与调试信息
- 把敏感配置迁移到环境变量
这些不是现在必须做,但都是这个项目继续变“正规”的下一步。
结语
这次 go-chat-api 的实践,最核心的一点是:
写一个小项目,真正重要的不是功能多不多,而是有没有形成完整闭环。
从 /ping 到 /chat,从假回复到真实 LLM 调用,从配置读取到错误排查,这条链路走通以后,很多零散知识才真正落地。
后面如果继续做多轮上下文、流式输出或前端页面,再继续记录新的实践过程。