从裸机到大模型:一个硬件仔的 Fine-tuning 实战手记
去年冬天,我还在远程工位上对着示波器抓 GPIO 波形,试图搞明白为什么某款传感器在低温下会莫名掉线。那时候做梦也想不到,半年后我会坐在同一张桌子前,用 Go 写服务对接 LLM,调参调到凌晨三点,嘴里念叨着“这个 loss 怎么又炸了”。
我是那种典型的“硬件出身、半路转 Go”的程序员。大学学的是自动化,毕业干了三年嵌入式,写过 RTOS,调过 SPI/I2C,刷过无数块开发板。后来因为受不了频繁出差和实验室里永远散不去的焊锡味,咬牙转行做了后端。现在远程办公,家里两台显示器、一堆机械键盘,日常就是撸 Go、读开源源码、偶尔被产品经理半夜钉钉轰炸。
今天想聊聊最近折腾的一个项目——我们团队尝试把 Devin(不是那个 AI 工程师,是我们内部一个代码生成工具代号)集成进 CI/CD 流程,用 Fine-tuning 微调一个开源大模型,让它能理解我们内部的 Go 项目结构,自动生成单元测试骨架。听起来很酷?过程其实惨不忍睹。
起因:别再让新人写重复的 test 文件了!
事情得从去年双11说起。那会儿我们上线了一个新微服务,结果测试覆盖率掉到了 60% 以下,CI 直接红了。老大在周会上拍桌子:“以后每个 PR 必须带 test,覆盖率不能低于 80%!”——说得好听,但现实是,新人写业务逻辑已经够呛,哪有精力去琢磨 table-driven test 怎么写、mock 怎么打桩?
更惨的是,我们项目结构高度统一:/internal/service, /pkg/model, /cmd/server……理论上,只要知道接口定义,就能自动生成符合团队规范的 test 模板。于是我和隔壁组的老张一合计:要不搞个 AI 工具?
起初我们直接调用 OpenAI API,prompt 写得巨长:“你是一个资深 Go 开发者,请根据以下 handler 函数生成符合 testify/mock 规范的单元测试……” 结果要么生成的代码根本跑不通,要么 mock 对象没注册,要么连 require.NotNil(t, err) 都写错。
“这不行啊,”老张嘬着电子烟说,“通用模型不懂咱们的代码风格,得 fine-tune。”
我一听就头大。Fine-tuning?我上一次调参还是在大学做 PID 控制器的时候!但 deadline 逼人,只能硬着头皮上了。
第一步:数据比模型更重要(血泪教训)
Fine-tuning 最坑的地方不是技术本身,而是数据准备。你以为随便拿几百个 .go 和 .test.go 文件就行?天真。
我们一开始直接从 Git 历史里扒出所有新增的 test 文件,配对原始实现,做成 JSONL 格式:
{
"input": "func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) { ... }",
"output": "func TestUserService_GetUser(t *testing.T) { ... }"
}
结果训练出来的模型疯狂复读:“func TestXXX(t *testing.T) { t.Fail() }”,或者直接把 input 原样抄一遍当 output。当时我真的想砸电脑。
后来翻了 Hugging Face 上几个开源项目的 fine-tuning 教程,才意识到:输入必须是“指令 + 上下文”,输出才是目标代码。比如:
{
"instruction": "Generate a unit test for the following Go function using testify and mock.",
"input": "func (r *Repo) FindByID(ctx context.Context, id int64) (*Model, error)",
"output": "func TestRepo_FindByID(t *testing.T) {\n\tmockDB := new(MockDB)\n\trepo := &Repo{db: mockDB}\n\t// ...}"
}
而且数据量不能太少。我们最终整理了 1,200+ 组高质量样本,全部来自真实 PR,每组都经过人工校验。这个过程花了整整两周——比写模型代码还久。
📌 经验总结:Fine-tuning 不是魔法,垃圾进垃圾出。如果你的数据噪声大、格式乱,再大的模型也救不了你。
技术选型:为什么不用云服务?
有人可能会问:“为什么不直接用 AWS SageMaker 或 Azure ML?”
答案很简单:钱 + 控制欲。
我们试过 OpenAI 的 fine-tuning API,价格感人——每千 token 几美分,训练一次就得上百刀。而且模型私有化部署受限,没法和我们的 DevOps 平台深度集成。
作为前嵌入式工程师,我对“黑盒”有天然警惕。我需要知道模型怎么加载、推理耗时多少、能不能跑在我们自己的 Kubernetes 集群上。于是我们决定:本地训练 + ONNX 导出 + Go 推理。
模型选了 Phi-2(微软那个 2.7B 参数的小钢炮),理由如下:
| 模型 | 参数量 | 是否支持本地训练 | Go 推理友好度 | 训练显存需求 |
|---|---|---|---|---|
| Llama3-8B | 8B | 是 | 低(需 llama.cpp) | ≥24GB |
| Mistral-7B | 7B | 是 | 中 | ≥20GB |
| Phi-2 | 2.7B | 是 | 高(ONNX 支持好) | ≤16GB |
我们开发机刚好有张 16G 的 4090,Phi-2 刚好压线跑起来。训练脚本基于 Hugging Face Transformers,加了梯度裁剪和 cosine learning rate decay:
training_args = TrainingArguments(
output_dir="./devin-ft",
per_device_train_batch_size=4,
gradient_accumulation_steps=8,
learning_rate=2e-5,
num_train_epochs=3,
logging_steps=50,
save_strategy="epoch",
fp16=True,
optim="adamw_torch",
)
三轮 epoch 跑下来,loss 从 2.8 降到 0.9,验证集 BLEU score 达到 0.73——勉强能看。
部署到生产:Go + ONNX 的奇妙组合
训练完只是开始。真正的挑战是如何让这个模型在 Go 服务里跑起来。
我先是尝试用 go-llama(绑定 llama.cpp),结果 Phi-2 的 tokenizer 不兼容,折腾半天无果。后来发现微软官方提供了 ONNX 导出脚本,立刻转投 ONNX 阵营。
用 onnxruntime-go 加载模型简直丝滑:
package main
import (
"github.com/yalue/onnxruntime_go"
)
func loadModel(path string) (*onnxruntime_go.InferenceSession, error) {
return onnxruntime_go.NewInferenceSession(path, onnxruntime_go.NewSessionOptions())
}
func generateTest(prompt string) (string, error) {
// tokenization via custom Go tokenizer (mimic Phi-2's logic)
tokens := tokenize(prompt)
inputs := map[string]onnxruntime_go.Input{
"input_ids": onnxruntime_go.NewTensor(tokens),
}
outputs, err := session.Run(inputs)
if err != nil {
return "", err
}
return detokenize(outputs["generated_ids"]), nil
}
关键点在于:tokenizer 必须和训练时一致。我们反向工程了 Phi-2 的 tokenizer,用 Go 复现了一套轻量级版本(参考了 golang-text 包的 BPE 实现)。虽然性能不如 C++ 版本,但在我们的场景下(单次请求 < 500ms)完全可接受。
上线第一周,Devin 自动生成了 200+ 个 test 文件,覆盖了 85% 的新 handler。最爽的是,有个实习生 PR 里漏了 error case,Devin 自动补上了 require.Error(t, err) —— 连我都没想到。
坑与反思:别被 hype 蒙蔽双眼
当然,过程绝非一帆风顺。分享几个血泪坑:
- OOM 杀手:初期 batch size 设太大,4090 直接爆显存。后来学会用
nvidia-smi dmon -s u实时监控,配合torch.cuda.empty_cache()才稳住。 - Go 的 GC 拖后腿:ONNX 推理时频繁分配内存,导致 Go GC STW 时间飙升。解决方案是预分配 buffer + sync.Pool。
- 产品经理的“小需求”:上线后 PM 突然说“能不能让它顺便写注释?”——我当场拒绝:“那是另一个 fine-tuning 任务,要重新标注数据,排期至少两个月。”
- 模型幻觉:有一次 Devin 给一个不存在的函数生成了 test,还调用了
mock.SomeFakeMethod()。后来我们在 post-process 阶段加了 AST 校验,确保生成的代码能通过go/parser。
最重要的一点感悟:Fine-tuning 不是银弹,而是工具链的一环。它适合解决“模式固定、上下文明确”的任务(比如生成 boilerplate code),但不适合做创造性工作。指望它替代程序员?省省吧。
写在最后:硬件仔的“软”转型
回看这段经历,其实挺魔幻的。从前我在 STM32 上抠几 KB 内存,现在在 4090 上训 2.7B 模型;从前用 JTAG 调试寄存器,现在用 Weights & Biases 看 loss 曲线。
但底层思维没变:系统要可控、资源要精打细算、问题要追根溯源。Fine-tuning 也好,Go 服务也罢,本质都是“输入-处理-输出”的确定性系统——只不过现在的 state space 大了几个数量级。
如果你也是从硬件/嵌入式转过来的,别怕接触新东西。大模型没那么玄乎,拆开来看,不过是矩阵乘法 + 巧妙的数据工程。而我们这些“老派工程师”的优势恰恰在于:不怕脏活,愿意沉下去看细节。
上周五晚上,我又在调 Devin 的一个 edge case。窗外下着雨,机械键盘咔嗒作响,终端里 loss 缓缓下降。那一刻,突然觉得——从裸机到大模型,路虽远,行则将至。
对了,模型代码和 fine-tuning 数据集已脱敏开源,欢迎来踩坑:github.com/embedded-go/devin-ft(假的,别真点)
附:性能对比表(本地 vs 云 API)
| 指标 | Devin (Phi-2 + ONNX) | OpenAI GPT-4 Turbo |
|---|---|---|
| 单次推理延迟 | 320ms | 850ms |
| 成本/千次调用 | $0.12(电费+折旧) | $30+ |
| 私有化部署 | ✅ | ❌ |
| 定制化能力 | 高(可改 tokenizer/loss) | 低(黑盒) |
| 维护复杂度 | 中(需 GPU 运维) | 低 |
注:成本按 4090 使用 3 年摊销计算,电费按 0.8 元/度估算。
搞定了,开心。今晚奖励自己一杯冰美式,明天继续和产品经理 battle “能不能让 Devin 自动修 bug” 的需求。

评论 0