本文是「Android工程师的AI开发实战」系列第3篇。前两篇我们聊了RAG和Agent,这一篇进入微调------AI开发的"深水区"。
从一次失败的Prompt说起
上篇我们给Agent装了工具调用能力。工具会用了,但有个问题一直困扰我:模型的"语气"怎么改都不对劲。
我在做一个Code Review辅助工具,想让它用团队的风格给建议。往System Prompt里塞了20条示例,few-shot打满。结果呢?模型确实学了"建议使用xxx"的句式,但语气还是一股说明书味------我们团队那种"兄弟你这写法是认真的吗"的直球风格,它死活学不会。
更离谱的是每次请求都要带一大坨Prompt,token费蹭蹭涨,延迟也跟着上去了。这跟每次启动App都从网络重新拉全量配置一个道理------明显是架构设计有问题。
那一刻我意识到:Prompt工程有天花板,就像只靠Theme/Style改不了系统行为------有些东西,必须改源码。
微调是什么:从AOSP到定制ROM
如果你做过Framework层开发,这个类比秒懂:
通用大模型 = AOSP源码。功能完整但没特色。GPT-4就像原生Android------啥都能做,但不会帮你写出符合你们团队规范的代码。
微调 = OEM定制ROM。MIUI、ColorOS、HarmonyOS------都是在AOSP基础上做深度定制,不是从零写系统,而是在通用基础上让它在特定场景下表现更好、更有"个性"。
关键区别:微调≠从零训练(那叫pre-training,动辄百万美元级别)。微调是在训练好的模型上,用少量领域数据"教"它新技能或新风格------投入小、见效快。
Prompt工程的三个硬伤
Prompt的本质是"运行时配置"------在AndroidManifest里改参数、在BuildConfig里换字段。它有三个绕不过去的问题:
1. 上下文窗口有限。128K token听着多,但你不可能把整个代码规范+所有示例都塞进去。等于运行时把整个数据库加载到内存------迟早OOM。
2. token成本线性增长。每次请求都带一大段System Prompt。就像每次网络请求都重新下载缓存------明显该做持久化的事情。
3. 行为改不深。Prompt能影响"说什么",但改不了"怎么想"。你说"请用犀利的风格",它最多加个感叹号------推理模式和内在逻辑纹丝不动。
微调则是"编译期改代码"------直接修改模型权重,让它骨子里就按你要的方式思考和输出。
三种微调路线:full build vs incremental vs instant run
对编译速度深恶痛绝的Android开发者,看这个对比会特别亲切:
| 方式 | Android类比 | 显存 | 效果 |
|---|---|---|---|
| 全量微调 | full clean build | 4×模型大小 | 最佳 |
| LoRA | incremental build | 1.2×模型 | 接近全量 |
| QLoRA | instant run | 0.3×模型 | 微损 |
全量微调:有钱人的游戏
全量微调更新模型所有参数。7B模型FP16权重14GB,加上梯度+优化器状态,训练时需要约56GB显存。
这就像给大项目做full clean build------结果最完美但每次等40分钟。除非你有A100集群随便用,否则走不通。
LoRA:AI世界的热修复
LoRA(Low-Rank Adaptation)的核心思想,做过热修复的Android工程师会秒懂:
不改原始权重,加一层"差分补丁"。
Tinker/Sophix怎么工作的?不重新打包APK,生成一个小的diff patch,运行时合并到原始代码上。LoRA一模一样:
原始权重 W(冻结,不参与训练)
↓ 前向传播时相加
低秩补丁 ΔW = A × B(只训练这个)
↓
输出 = W·x + ΔW·x = (W + A×B)·x
关键点 → A和B的rank远小于W的维度,参数量仅为原模型的0.1%~1%
翻译成Android:W是你的Release APK(几十MB不动),ΔW是热修复patch(几十KB)。patch虽小,行为精准可控。
更妙的是:你可以给同一个base model加载不同的LoRA adapter。今天加"代码Review风格"patch,明天换"需求文档生成"patch------就像同一个APK加载不同的热修复包,一套基座多种人格。
QLoRA:消费级显卡的福音
QLoRA在LoRA基础上加了一招:把冻结的原始权重从FP16量化到4-bit NormalFloat。不仅增量编译,还把"基线代码"压缩了。
实际效果:7B模型QLoRA训练只要6GB显存。一张3090能跑13B,一张4090能怼30B+。个人开发者不用再对着8×A100的配置流口水了。
数据集构建:垃圾进垃圾出
微调最核心的不是算力,是数据。跟写单测一个道理------覆盖率99%但全是happy path的测试集,还不如20个精心设计的边界case有价值。
标准格式:instruction / input / output
微调数据的标准格式是三元组。写过BDD的人秒懂------Given/When/Then:
{
"instruction": "Review这段
Kotlin代码,指出问题",
"input": "fun load() {\n
runBlocking {\n
api.fetch()\n
}\n}",
"output": "兄弟,主线程
runBlocking是想ANR?
viewModelScope.launch
了解一下。"
}
注意output的风格------不是"建议使用协程替代阻塞调用",而是"兄弟你认真的?"这种团队真实语气。这就是微调要学的东西。
数据从哪挖?
Android团队最好的数据金矿:
• Code Review历史 --- 工蜂/GitLab的MR评论,天然的(代码, review意见)对
• IM技术讨论 --- 企微群里"这怎么实现→用xxx方案"的对话
• Wiki最佳实践 --- 改写成instruction格式
• Bug修复记录 --- issue描述+修复diff,天然的问题→方案对
数据质量清单(参考单测质量标准):• output是否正确?(assert通过吗)• 覆盖边界case了吗?(不只是happy path)• 有互相矛盾的条目吗?(两个test冲突)• 数量:500~2000条起步才有明显效果• 多样性:同一类问题换多种表述方式
实战:LoRA微调代码Review助手
理论够了,上手。基座选DeepSeek-Coder-7B(开源、代码能力强、单卡可跑),用QLoRA方案。
Step 1:环境和依赖
# 核心四件套
pip install \
transformers \
peft \
bitsandbytes \
datasets accelerate
# peft → LoRA/QLoRA官方实现
# bitsandbytes → 4-bit量化
# accelerate → 混合精度+多卡
Step 2:加载4-bit量化模型
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
)
from peft import (
LoraConfig,
get_peft_model,
prepare_model_for_kbit_training,
)
# 量化配置 --- 类比ProGuard压缩
bnb_cfg = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=(
torch.float16
),
bnb_4bit_use_double_quant=True,
)
MODEL = (
"deepseek-ai/"
"deepseek-coder-7b-"
"instruct-v1.5"
)
model = (
AutoModelForCausalLM
.from_pretrained(
MODEL,
quantization_config=bnb_cfg,
device_map="auto",
)
)
tokenizer = (
AutoTokenizer
.from_pretrained(MODEL)
)
Step 3:配置LoRA参数
# 定义LoRA adapter
# = 定义热修复patch的作用范围
lora_cfg = LoraConfig(
# rank: patch的"厚度"
# 16对大多数任务够用
r=16,
# 缩放因子,一般 = 2×r
lora_alpha=32,
# 打patch的目标层
target_modules=[
"q_proj",
"v_proj",
"k_proj",
"o_proj",
],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
# 冻结原始权重 + 挂载adapter
model = prepare_model_for_kbit_training(
model
)
model = get_peft_model(
model, lora_cfg
)
model.print_trainable_parameters()
# → trainable: 13.1M
# → all: 6.9B
# → trainable%: 0.19%
只训练0.19%的参数------7B模型变成13M参数的小活儿。这就是LoRA的魅力。
Step 4:数据预处理 + 训练
from datasets import load_dataset
from transformers import (
TrainingArguments,
Trainer,
)
def fmt(s):
return (
"### Instruction:\n"
f"{s['instruction']}\n\n"
"### Input:\n"
f"{s['input']}\n\n"
"### Response:\n"
f"{s['output']}"
)
ds = load_dataset(
"json",
data_files="cr_data.jsonl",
)
def tok(sample):
enc = tokenizer(
fmt(sample),
truncation=True,
max_length=1024,
padding="max_length",
)
enc["labels"] = (
enc["input_ids"].copy()
)
return enc
ds = ds.map(tok)
args = TrainingArguments(
output_dir="./cr-lora",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
# lr要小------微调不能太猛
learning_rate=2e-4,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
fp16=True,
logging_steps=10,
save_strategy="epoch",
)
Trainer(
model=model,
args=args,
train_dataset=ds["train"],
).train()
# 保存adapter(只有~30MB)
model.save_pretrained("./cr-lora")
1000条数据、3 epochs、单卡3090:约20分钟跑完。产出的adapter只有30MB------你的"代码Review风格补丁"就做好了。
Step 5:加载adapter做推理
from peft import PeftModel
# 加载base + adapter
base = (
AutoModelForCausalLM
.from_pretrained(
MODEL,
device_map="auto",
torch_dtype=torch.float16,
)
)
model = PeftModel.from_pretrained(
base, "./cr-lora"
)
# 可选:合并adapter到base
# 推理速度和原始模型一样
model = model.merge_and_unload()
merge_and_unload()把adapter永久合并回base权重------合并后推理性能和原模型完全一样,没有额外开销。就像热修复最终还是要合入正式版本发版。
参数调优:和性能优化一样的方法论
微调调参和Android性能调优一个路子------不是瞎试,有方法论:
| 参数 | Android类比 | 经验值 |
|---|---|---|
| learning_rate | 动画duration | 1e-4~3e-4 |
| epochs | 重试次数 | 2~5次 |
| batch_size | 线程池大小 | 受显存限制 |
| rank ® | 缓存容量 | 8~64 |
我的策略:先用默认值(r=16, lr=2e-4, epochs=3)跑一版baseline,看效果再针对性调整。跟Android一样------先跑通再优化,别在第一版追求完美。
部署:云端 vs 本地 vs 端侧
微调完了,怎么上线?跟Android的架构选型一个逻辑:
模型部署选择?
↓
云端(vLLM/TGI) → 类似后端API服务。无限算力、有网络延迟。适合7B+大模型生产使用
本地(Ollama/llama.cpp) → 类似前台Service。零延迟但吃本地资源。适合开发调试
端侧(MLC-LLM/TFLite) → 类似嵌入SDK。离线可用但需极度压缩(3B以下)。特定场景
我这个CR Review助手选的是云端------vLLM部署在一张A10G上,暴露OpenAI兼容接口,对接工蜂Webhook,每次新MR自动触发review。延迟在2~3秒,完全可接受。
踩坑记录(血泪教训)
1. 数据质量 > 数据数量
第一版用了5000条自动提取的CR评论,效果很差。后来手动筛了800条高质量的,效果直接起飞。100个flaky test不如10个稳定test------微调数据一个道理。
2. 过拟合的信号
模型开始复述训练集里的具体类名、变量名------这就像背题的学生,换个说法就懵。解法:减epochs、加数据多样性、调大dropout。
3. 灾难性遗忘
微调太狠,模型连基本Kotlin语法都答不好了。解法:训练数据混入10~20%的通用编程Q&A,维持基础能力。就像定制ROM改太多底层导致基本功能挂了。
4. 评估要趁早
别等训完再测。每个epoch结束跑test set看变化,跟Android CI一样------每次commit都跑,别攒一堆再排查。
什么时候该微调?
需要改变模型行为?
↓
Prompt/RAG能解决吗?
↓
能 → 就用Prompt/RAG,别上微调
不能 → 有500+高质量数据吗?
↓
有数据 → 上LoRA!投入产出比最高
没数据 → 先用RAG撑着,同时积累数据
说实话,大部分场景Prompt+RAG够用了。微调是"重武器",适合三种情况:需要改变模型的思考模式/语气风格、需要极低推理延迟(省掉长Prompt)、需要在垂直领域达到专家级水平。
下一篇是系列终章:把RAG、Agent和微调三件套组合起来,搭一个完整的"AI Android开发助手"------从架构设计到生产部署,把前三篇的东西全部打通。就像从Fragment/Service/Broadcast Receiver的单独学习,到最终组装一个完整App。
如果你也在尝试微调,评论区聊聊数据集是怎么构建的------这部分其实是整个流程最耗时间的,比训练本身难多了。好数据集价值连城。