免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

文本决策一次搞定:BERT+Marker本地化推理方案实战

文本决策一次搞定:BERT+Marker本地化推理方案实战 最近在调一套本地 coding agent 工具链的时候被一个很基础的问题卡了很久自然语言指令进来之后怎么把“该做什么、动哪个文件、要不要测试、优先级多高”这些文本决策给稳定地结构化出来。之前接的是一个闭源决策接口姑且叫它 Jev 吧效果确实不错但每次调用都要走网络、按次计费而且把用户代码片段送出去心里总不踏实。后来我换了条路线用 marker 机制把多个文本决策全部压进 BERT 的一次 forward 里整套决策在本地跑完。这个项目我命名为 Laya实测下来单次推理从原来调接口的 200-300ms 降到了 30ms 以内成本直接归零决策结果也更稳定了。这篇文章把完整思路、数据构造、训练细节和踩过的坑全记下来给想本地化文本决策的朋友一条参考路线。1. 为什么要做“一次 forward 的文本决策”1.1 先搞清楚这类文本决策服务到底在解决什么问题文本决策这个词听起来有点抽象拆开看其实就是“把一个非结构化输入映射成若干个结构化字段”。拿我最常见的场景举例用户在编辑器里说“帮我把接口 /user/info 改成异步然后加个超时重试”这条指令本身只是一个字符串但要让 coding agent 真正去执行就必须解出几个明确结论动作类型是 refactor目标是 user_service 模块是否涉及前端要不要补测试优先级怎么定。这些结论就是文本决策。Jev 这类闭源服务本质上就是在做这件事输入一段指令输出一组结构化的决策结果通常是 JSON。从产品角度看它很方便接个 SDK 就能用。但使用过程中我越来越难受——延迟不稳定高峰期单次往返能到 400ms 以上费用虽然单次不贵但量一大就是持续成本最关键的还是隐私代码片段本身是高度敏感的信息每次都要发到别人服务器上过一遍心理负担很重。所以我当时的目标很明确能不能在本地用一个轻量模型把同样类型的决策完整做出来而且我不想要“多个模型各管一摊”的方案而是希望一条自然的用户指令进来模型一次前向计算就把所有决策维度全部输出。这个需求最终导向了标题里那个方案用 marker 把决策点全部摆进输入序列让 BERT 一次 forward 全解出来。1.2 传统方案的成本账为什么算下来都不划算我做选型之前先算了一笔账把几种常见方案列出来对比结论非常清晰。方案A每个决策任务单独训一个 BERT 分类器。比如动作类型一个模型目标文件一个模型前端相关判断一个模型。这么做的问题很直接N 个任务就是 N 份显存、N 倍推理时间。以 BERT-base 在普通 CPU 上的推理来看单次 forward 大约 30ms如果同时要做 5 个决策那就是 150ms 起步而且是串行调用。这在交互式工具里已经属于能感知到的卡顿。方案B多任务学习共享一个 BERT backbone用 [CLS] 位置的输出向量接多个分类头。这个方案 forward 次数确实只有一次但实战效果不太理想。原因后面会详细讲简单说就是所有任务都从一个压缩了全句语义的向量里取信息任务之间会互相打架尤其是当一个任务是粗粒度的意图分类、另一个任务是细粒度的目标抽取时[CLS] 向量很难两头讨好。方案C继续用闭源服务。每次调用都是网络往返延迟 200ms 打底还要计费、限流。算一笔经济账假设一天 10 万次调用每次 0.002 元一个月就是 6000 元而这还只是一个小功能的用量。更别提如果目标场景是离线部署或者内网环境闭源方案直接就出局了。下面这张表是我当时做决策时列的放在一起看更直观方案forward次数显存占用CPU延迟扩展性部署方式每任务一个模型NN倍N×30ms差本地单模型 [CLS]多head1固定约30ms任务间干扰明显本地单模型 marker1固定约30ms好任务可独立扩展本地闭源服务网络调用无200ms以上受制于上游在线选择其实已经很明确了要在本地部署、要一次 forward、要多任务间尽量独立marker 路线是综合成本最低的方案。2. marker 机制的核心原理让 BERT 学会“定点答题”2.1 为什么 [CLS] 不够用marker 解决了什么问题B 站上很多讲 BERT 分类的文章都会告诉你“用 [CLS] 的输出接分类头”这个说法没错但它隐含了一个前提你只有一个整句级别的分类需求。一旦要同时做多个不同维度的决策[CLS] 这个向量就开始不够用了。原因在于 BERT 的 [CLS] 向量是整句话语义的压缩汇合。用一个很粗糙的类比它像一个被要求“把所有信息都记在脑子里”的人你问他“意图是什么”他能答问他“目标是哪个文件”他也能答但同时问十个不同维度的问题信息就开始互相覆盖、互相干扰。而且 [CLS] 是偏向全局语义的遇到“目标文件是哪个”这类需要从局部文本里抠信息的任务它天然就吃亏。marker 的思路完全不同既然一个全局向量装不下多个决策那我就在输入序列里为每个决策专门放一个“答题位置”。这个位置是一个特殊的 token通常用 [MASK] 或者自定义的 [D1]、[D2] 之类来充当。模型在微调之后会学会在每个 marker 位置只输出和该位置负责的决策相关的信息。这就好比你不再让一个人凭空回答所有问题而是给一张答题卡每道题在固定位置留了空他只需要在对应的空里填答案。这个机制的底气来自 Transformer 的自注意力。每个 marker 位置的 token 会通过 attention 去“关注”输入文本中和自己相关的部分从而把决策需要的局部信息聚合到自己这个位置上。关注哪些词、聚合什么信息全部是微调阶段自动学出来的。2.2 用一张“答题卡”结构组织多个决策任务知道了 marker 的原理接下来就是怎么把它组织成模型实际看到的输入。我采用的模板结构是这样[CLS] 用户指令原文 [SEP] 提示词1: [MASK] 提示词2: [MASK] ... [SEP]其中提示词的作用非常重要。它不是装饰而是给 marker 一个语义锚点。比如“动作类型: [MASK]”和“目标文件: [MASK]”模型看到“动作类型”这四个字就知道后面这个 [MASK] 应该输出动作相关的结果而不是目标文件相关的结果。一个完整例子[CLS] 帮我把接口 /user/info 改成异步并且加个超时重试 [SEP] 动作类型: [MASK] 目标文件: [MASK] 前端相关: [MASK] 需要测试: [MASK] 优先级: [MASK] [SEP]这五个 [MASK] 就是五个决策点。它们在输入序列里的位置是固定的训练时我们只在这五个位置计算损失其他位置的输出全部忽略。推理时五个位置分别取 hidden state各自过一个分类头就能得到五个决策结果。为什么中间要放一个 [SEP]因为原始指令和“答题区”属于两种不同性质的文本放进不同的 segment 之后注意力可以更清晰地划分区域。实际测试中加了 [SEP] 的效果比直接把提示词接在原文后面要好不少大概有 2-3 个百分点的提升。2.3 为什么选 BERT 而不是生成式模型围绕这个方案有不少朋友问过现在生成式模型这么强为什么还要用 BERT我的理由很实际。第一BERT 是 encoder-only 结构双向注意力一次前向就能输出所有位置的表示天然适合“按位置作答”的任务。生成式模型是 decoder-only答案需要逐个 token 生成延迟不可控一次要输出五个决策结果至少要生成十几个 token本地 CPU 上跑起来非常吃力。第二BERT-base 体积小。110M 参数、约 400MB 的模型文件量化之后还能再压一压普通笔记本 CPU 就能流畅跑。对比动辄几十 GB 的生成式模型完全不是一个量级。第三这个任务本质上是分类和抽取的组合不是开放文本生成。决策结果需要高度稳定、结构化而不是自由发挥。BERT 加一个线性头的方案天然输出固定的标签集合里的某一个可控性远好于让生成模型“选一个”答案。下面这个表是我当时对比后的结论维度BERT类encoder模型生成式模型推理方式一次前向逐token生成本地CPU延迟约30ms数百ms起步结果可控性固定标签集合需要约束解码部署体积400MB左右数GB以上按位置多任务天然支持需精心构造prompt3. 实操把决策压进一次 forward 的关键步骤3.1 数据准备把自然语言指令构造成带 marker 的训练样本数据是整个方案的地基。我是从真实用户请求里扒了一批指令然后用 JSON 形式标注了每个指令对应的多个决策字段。标注格式大概是{ text: 帮我把接口 /user/info 改成异步并且加个超时重试, action: refactor, target: user_service, frontend: no, test: yes, priority: p1 }这里的 text 是原始用户指令后面五个字段就是需要模型决策出来的结构化结果。数据标注阶段我的做法是先写一批正则规则生成“弱标签”比如命中“改成异步”“重构”就标成 refactor命中“读一下”“看看”就标成 fetch然后把置信度低的样本挑出来人工修正。这样把纯人工的工作量降了一大半。样本量方面我一开始只标了 800 条左右就开始训练了效果已经能覆盖大部分场景。后面一边用一边补到 3000 条左右基本就稳定了。3.2 数据加载与 marker 位置对齐最容易翻车的环节构造训练样本时最容易被忽视、也最容易翻车的环节是 marker 位置对齐。BERT 的 tokenizer 会把中文按字切英文按 subword 切所以我们拼好的“提示词1: [MASK]”经过 tokenizer 之后[MASK] 在 input_ids 里的下标位置会随着前面文本长度的变化而变化。不能在数据预处理阶段就固定写死位置必须在运行时动态去查。我的做法是把完整模板文本传给 tokenizer然后在返回的 input_ids 里直接搜索 [MASK] 对应的 token id拿到它在序列中的实际位置。具体代码是这样的from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) mask_token_id tokenizer.mask_token_id def build_marker_input(text, fields): fields: 有序字典键为提示词值为标签如 {动作类型: refactor, 目标文件: user_service} prompt_part .join([f{k}[MASK] for k in fields.keys()]) full_text f{text} [SEP] {prompt_part} tokens tokenizer( full_text, return_tensorspt, paddingmax_length, truncationTrue, max_length128, ) input_ids tokens[input_ids][0] # 搜索所有 [MASK] 的位置 mask_positions (input_ids mask_token_id).nonzero(as_tupleTrue)[0].tolist() return tokens, mask_positions有个细节要注意如果你的模板里有多个 [MASK]tokenizer 可能会把重复 token 做合并吗实际上不会[MASK] 是 special token不会被去重每次出现都会保留独立的 token id所以搜索出来几个就是几个。3.3 训练配置损失函数、标签映射与超参数训练阶段的核心是两件事标签怎么映射损失怎么算。标签映射有两种做法我一开始用的是“标签即词表 id”。意思是把每个决策的候选标签直接映射到 tokenizer 词表里的某个 token id。比如“refactor”这个词在 BERT 词表里存在对应的 token id 可以直接拿来当监督信号。这种方式省掉了一个额外分类头模型只需要在 [MASK] 位置输出词表维度的 logits然后取对应标签词的 logit 算损失。但这个方案有个大坑很多标签不是一个 token 能表示的。比如“user_service”这个标签经过 tokenizer 会被切成 “user”、“_”、“service” 三个 token没法直接映射到单个 token id。这种情况下有两个出路要么把标签改成一个 token 能表示的代号比如改成“us”要么换成第二种方案——在 [MASK] 位置的 hidden state 后面接一个小的线性层输出维度就是该决策的候选标签数量。第二种方案更通用我最后用的就是它。训练代码的核心流程是这样import torch import torch.nn as nn from transformers import AutoModel class MarkerHead(nn.Module): def __init__(self, hidden_size, num_labels): super().__init__() self.fc nn.Linear(hidden_size, num_labels) def forward(self, x): return self.fc(x) # 假设有三个决策维度每个维度一个分类头 num_labels {action: 5, target: 8, test: 2} heads nn.ModuleDict({ name: MarkerHead(768, num_labels[name]) for name in num_labels })训练时对每个决策维度分别取对应 [MASK] 位置的 hidden state送入各自的 head 得到 logits再和该维度的标签计算交叉熵损失最后把几个损失求和回传。代码逻辑可以参考def train_step(model, heads, batch): input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) # mask_positions: 形状 [B, num_tasks] mask_positions batch[mask_positions].to(device) # labels: 形状 [B, num_tasks] labels batch[labels].to(device) outputs model(input_ids, attention_maskattention_mask) hidden outputs.last_hidden_state # [B, L, H] total_loss 0 for i, (name, head) in enumerate(heads.items()): pos mask_positions[:, i] # [B] vec hidden[torch.arange(input_ids.size(0)), pos] # [B, H] logits head(vec) # [B, num_labels_i] loss nn.CrossEntropyLoss()(logits, labels[:, i]) total_loss loss return total_loss要注意一点torch.arange(input_ids.size(0))需要放到和 hidden 相同的设备上否则报错。超参数方面我常用的配置是BERT 底座的 learning rate 设为 2e-5分类头的 learning rate 可以放大到 2e-4优化器用 AdamWlinear warmup 0.1 比例batch size 32训练 3-5 个 epoch。数据集小的话epoch 可以适当多一些但要注意别过拟合我会在训练集上留 10% 做验证集每个 epoch 结束看一眼各决策维度的准确率。3.4 推理一次 forward 解出全部决策结果训练完成后推理逻辑非常简单。把用户指令按训练时的模板构造好tokenizer 编码forward 一次然后对每个决策维度取对应 [MASK] 位置的 hidden state过分类头取 argmax就得到了所有结果。model.eval() with torch.no_grad(): tokens, mask_positions build_marker_input(text, fields) input_ids tokens[input_ids].to(device) attention_mask tokens[attention_mask].to(device) hidden model(input_ids, attention_maskattention_mask).last_hidden_state results {} for i, (name, head) in enumerate(heads.items()): pos mask_positions[i] vec hidden[0, pos] logits head(vec) pred_id logits.argmax(-1).item() results[name] id_to_label[name][pred_id]一个实际运行效果是这样的输入: 帮我把接口 /user/info 改成异步并且加个超时重试 输出: action: refactor target: user_service frontend: no test: yes priority: p1性能和原来是天壤之别。以我手头一台 i5-1240P 笔记本为例CPU 上 BERT-base 单条推理约 25-35ms换成 Jev 那个闭源接口单次网络往返平均 260ms。而且本地推理是恒定延迟不存在网络抖动的问题。显存占用方面batch size 1 的时候 GPU 显存占用不到 2GB集显都能跑。4. 踩坑记录与效果调优4.1 常见问题速查表这套方案我在实际调的过程中踩了不少坑整理成速查表方便你直接照方抓药问题现象原因解决方案marker 位置漂移训练时 loss 不收敛或乱跳tokenizer 分词后位置变化位置写死运行时动态搜索 [MASK] 位置标签切词“user_service”标签映射不到单 token标签被 subword 切碎改简短代号或改用线性分类头任务间干扰多个决策准确率不平衡marker 距离太近注意力互相污染拉开 marker 间距增加 [SEP] 分隔标签不平衡低频标签永远学不好样本分布悬殊损失函数加权重或用重采样过拟合验证集准确率下降样本少、epoch 多增加数据、early stopping、dropout长指令截断尾部决策点被截掉原文过长导致 marker 没进入序列调整 max_length 或把决策区提前4.2 一个典型的认知误区把 [MASK] 当成让模型“填空”这是我最初犯的错误也看到不少朋友犯同样的错误。BERT 预训练的时候会用 [MASK] 做掩码语言模型让模型根据上下文预测被掩码的词。这个机制给大家留下了根深蒂固的印象于是很多人以为我们这里用 [MASK] 也是让模型自己“填答案”。实际上完全不是一回事。我们的 [MASK] 在输入里只是一个位置占位符它本身没有任何需要被预测的信息。模型被微调之后要做的是看到这个位置、结合前面的提示词从原始指令文本里提取决策所需的信息然后通过这个位置的 hidden state 去分类。监督信号来自 label 和分类头的交叉熵而不是来自 MLM 的“预测被掩码词”任务。理解了这个区别之后你会发现 [MASK] 放到哪里非常重要。它的本质是“答案的容器”容器所在的位置决定了模型提取哪方面的信息。所以别把 [MASK] 当成花架子随便放它是整个 marker 机制里最关键的锚点。4.3 效果不够好的时候按照这个顺序调如果你的初步效果不理想我的建议是按下面这个顺序排查和调优不要一上来就换模型。第一步加数据。大部分“效果不好”都源于数据量不足或标注噪声。先把每个决策维度的样本分布拉到均衡再看效果有没有提升。第二步检查 marker 位置和提示词。比如“动作类型: [MASK]”这个写法如果模型总是把动作类型搞混可以试试把提示词改得更具体比如“需要执行的动作类别: [MASK]”给模型更明确的信息。第三步尝试冻结 BERT 底层。BERT-base 有 12 层 Transformer底层学到的是通用语言特征顶层更接近任务相关特征。我试过冻结前 8 层、只微调后 4 层加分类头在小数据集上效果反而更好也不容易过拟合。第四步换底座模型。中文场景可以试试 RoBERTa-wwm-ext、DeBERTa-v3 之类的变体通常能带来一两个百分点的提升。代价是模型体积和推理延迟略微增加需要做个权衡。第五步调各任务损失权重。多个决策任务的难易程度不一样简单的“是否涉及前端”可能收敛得很快难的“目标文件”收敛得慢。给难任务更高的损失权重能让模型把精力优先放在难任务上。5. 扩展经验这套方案还能往哪里用Laya 这个名字是我给这套 marker BERT 方案起的代号实际它的价值不只是平替一个闭源接口。把决策压进一次 forward 的思路可以迁移到非常多场景。比如内容审核方向。一条用户留言需要同时判断是否涉敏、是否广告、是否人身攻击、是否需要人工复审。这本质就是四个决策维度完全可以用同一套模板构造数据一次 forward 全出结果。再比如日志异常分析。“这条日志属于哪个模块、什么错误级别、是否需要告警”三个问题同样能压进一次 forward。相比传统的正则加规则引擎这套方案泛化性好得多遇到没见过的日志格式也能基于语义判断。还有一点我觉得很值得说的把所有决策从闭源接口迁移到本地后不只是省了钱更重要的是决策链条完全可控了。闭源接口哪天改协议、调整限流策略、甚至下架都不可控现在模型在自己手里用什么底座、怎么调优、何时升级全部自己说了算。如果在做类似本地化决策服务的同学我建议不要犹豫直接拿一条你最近的请求过来标几个字段用这套 marker 思路跑一版试试。成本也就是几小时的标注和一晚上的训练大概率会给你惊喜。最后再分享一个小技巧如果你的决策结果需要对接其他工具链比如要喂给 codex 或 opencode 这类编码代理建议让模型直接输出 JSON 结构。做法很简单给每个决策维度设计好 JSON 字段名推理完成后汇总成字典再转成 JSON。这样 Laya 就变成了一个完全本地、低延迟、可私有化部署的决策服务层原来闭源接口提供的东西现在自己全都有。
返回列表