从裸机到大模型:一个硬件仔的 Fine-tuning 实战手记

邓庆华
2026-04-03 18:37
阅读 499

去年冬天,我还在远程工位上对着示波器抓 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 蒙蔽双眼

当然,过程绝非一帆风顺。分享几个血泪坑:

  1. OOM 杀手:初期 batch size 设太大,4090 直接爆显存。后来学会用 nvidia-smi dmon -s u 实时监控,配合 torch.cuda.empty_cache() 才稳住。
  2. Go 的 GC 拖后腿:ONNX 推理时频繁分配内存,导致 Go GC STW 时间飙升。解决方案是预分配 buffer + sync.Pool。
  3. 产品经理的“小需求”:上线后 PM 突然说“能不能让它顺便写注释?”——我当场拒绝:“那是另一个 fine-tuning 任务,要重新标注数据,排期至少两个月。”
  4. 模型幻觉:有一次 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝