叶小钗

万字血泪史:去TMD微调,因为这事我被怼脸连骂2小时!

手里有些AI岗位;想找工作的同学可联系,急缺高级产品经理

关注公众号,回复1,与我交个朋友吧

近期有学员出去找AI相关工作,回来给我反馈会问到一些关于模型微调的问题,而他之前学的时候没太认真,希望能给补补课。

怎么说呢,事实上对于模型微调这件事,我是不愿意接触的,因为他带给了我很多不好的回忆...

时间又要往前推两年,那时候国内还在卷模型预训练、微调这一套路径,俗称炼丹,也不知道是有幸还是不幸,在这个阶段我们也做了很多训练。

那个时候一般的公司可选择的基座模型很少,比如:Bloom、LLaMA、GLM甚至GPT2。

算力成本的话比现在贵10倍不止,因为GPT-4-32k的账号当时是一票难求(最贵可以卖到8万),有些团队一晚上去跑数据,没做好控制,十多万银子搞不好就打水漂了...

而且当时数据成本也奇高,获取优质数据的手段比较单一,也根本没有所谓的数据蒸馏的说法,因为事实上不允许,根本搞不到账号,搞到了Token费用也耗不起。这些情况要到24年,微软云Azure账号随意购买后才逐渐改善。

只不过问题也就出现了,很多公司由于技术积累、资金所限,花大力气、下大决心训练的小模型(据我所知,7B、13B为主),刚刚取得一点成绩,回头GPT版本一个更新、LLaMA2一个发布,就全完犊子了!

训练的小模型,效果很快被财大气粗的大模型持平或超越了,而团队根本没有太多的成本再进行训练了

另一方面,一般公司在模型研发侧能力为0,就算训练的效果较好使用场景也非常有限,只能做简单的任务,最常见的为:关键词提取,原因也很简单,90%的公司都没办法扩展模型上下文,国内能做的人很少!

这里最终结果就是:很多实力不够雄厚、积累不够深入的公司,在初步尝试后,就迅速放弃了这条技术路径,并且隐约有点被AI大势裹挟的感觉,而在这场炼丹狂欢中不同公司遭遇的试错成本各不相同,但数百万应该是有的...

我非常清晰当时做项目复盘的时候,老板问我为什么当时要选择这条技术路径,知道试错成本有多高吗?我整个人垂着头都缩作一团了,硬是抬不起来,被贴脸输出2小时有余,事后一个月都没缓过来...

这里可能有同学就会问了:既然如此为什么当时那么多公司要选择自己做训练呢?

这个应该就是时代的局限吧,当时国内风气是以套壳为耻,整个这个风气我记得好像是在Cursor出来后,大家发现套壳居然可以这么屌,才慢慢开始转变。

所以,问题也就来了,在当前基座模型如此屌的情况下,什么时候才会用到微调?

WHY 微调

首先,我们说下不应该用到微调的两个典型场景:

不应该微调

第一,风格、语气和品牌的个性化定制。原来上下文窗口比较珍贵,模型理解能力较弱,也可能会遗忘,所以我们希望从模型底层注入了风格,一劳永逸,于是会用到微调,现在来说这个场景消失了。

第二,复杂结构化输出与特定格式遵循。比如将客户需求自动转化为公司内部JSON格式的工单。这种微调的原因依旧是模型能力较弱,不微调担心出错,但现在也不需要了;

然后,我们再说下几个可以用到微调的场景:

可以微调

第一,在特定领域提升“直觉”和“确定性”

虽然RAG可以提供知识,但微调可以提升模型在领域内的思维模式。

比如一个代码模型,在用了某公司所有的内部代码库微调后,会对该公司特有的编程规范、私有库函数、甚至常见的bug模式有更深的理解,它不仅能引用知识(RAG),更能以符合公司习惯的方式去思考和建议代码。

在这个场景下,RAG属于资料查询,而微调会倾向于内置SOP,将模型变成领域专家,比如我们前段时间在搞的芯片编程就会用微调的方式进行。当然这里还有个原因,就是需要学习的内容太多了。

第二,成本与延迟优化

对于高频调用的大型应用,每次都调用云API成本高昂且延迟较高,在这个时候对小模型的微调也就派上了用场。

只不过这里要特别注意,我们搞一个小模型去微调,目标肯定不是让他去扮演一个缩小版的GPT、去处理开放领域、创造性的对话

这类小模型微调真正的价值在于,去处理特定任务、去完成那些定义明确、边界清晰、对速度和成本极度敏感的任务。

比如以下场景:

  1. 输入输出标准化:输入是短文本(如用户query、一句话、一个搜索词),输出是结构化数据(如分类标签、布尔值、JSON对象);
  2. 高频率、低延迟要求:每秒可能需要处理成千上万次请求,对响应要求较高的场景;
  3. 领域特定:任务高度依赖企业自身的业务逻辑和数据;

这里做下总结:如果只为补充知识/最新事实,那么优先RAG,微调不善于塞知识;如果是为影响模型的输出能力(包括文字规则、格式与速度),可以用微调。

接下来,我们继续探讨可以微调的两个场景:

提升直觉和确定性

这里的逻辑是:RAG补知识,微调补思维,目标是把资料检索型回答变成按我们行业SOP思考+稳态输出。在动作之前首先要思考一点:

一、什么时候要微调?

这里有几个点要注意:

  1. 能够将规则或者SOP梳理出来,行业里面有明确的标准的,你不要搞一个开放命题过来微调,这样大家都不要玩了;
  2. 在当前大模型能力基础上,提示词怎么调都不好使,准确率到95%就跑不动了;
  3. 或者有一些必须要遵守的边界、触碰不得的红线;
  4. ...

准入搞清楚后就到了问题的核心,数据

二、数据怎么来

这个场景的数据一般都是高结构化的,比如:

  1. 整理出来的金标准,比如SOP、公司规范、合规条款,数量的话1k–5k条,必须一条就是一个“判断/建议的锚点”;
  2. 负面案例,常见错法&反例,也要1-5k条,明确为什么错与应该怎么改;

要特别注意的是:微调数据不仅要问答对(Q,A),还得包含推理过程(Q,Reasoning,A)

以芯片编程为例:数据不应该只是“需求 -> 代码”,而应该是“需求 -> 分析(为什么这里要用这个私有库函数?需要考虑哪些时序和功耗问题?)-> 代码”。

这样微调出的模型,在遇到新问题时,会更倾向于模仿这种推理路径,而不仅仅是输出一个答案,它的确定性就体现在其推理过程符合我们设定的规则。

这里芯片的场景过于晦涩,我给大家举个简单例子:

三、数据案例

首先是正面案例:

Q:“你好,我刚才下班回家,把车停在小区路边了。吃完晚饭下来发现副驾驶的车窗被砸了,车里放的一个笔记本电脑包不见了。电脑是公司财产,估计值一万多块钱。我现在该怎么办?”

Reasoning:
事件定性: 用户描述的核心是“车窗被砸”和“财产丢失”,这是一个典型的“机动车盗抢”案件,而不是普通的“车损”。应优先归类到“盗抢险”。
责任初判: 车辆处于停放状态,且被盗物品为车内财产。根据条款,盗抢险通常涵盖车内财产损失,但需确认用户是否投保了“机动车盗抢险”及“车内财产附加险”。初步判断保险公司有责任,但需核实保单。
信息提取: 需要提取的关键结构化信息包括:事发地点:小区路边、受损部位:副驾车窗、损失物品:笔记本电脑、损失金额预估:10000元、案件类型:盗抢。
下一步动作: 指导用户立即报警(盗抢案件必须要有警方证明)并保护现场。

A:
{
"案件类型": "盗抢险",
"保险责任初步判断": "可能赔付,需核实保单",
"关键信息": {
"事发时间": "晚间",
"事发地点": "小区路边",
"车辆损伤": "副驾车窗被砸",
"财产损失": "笔记本电脑包",
"损失金额预估": "10000元"
  },
"下一步建议": [
"请立即拨打110报警,并获取案件回执或证明。",
"请对现场和车辆受损部位进行拍照留存。",
"请通过App上传保单信息,核实具体险种。"
  ]
}

数据准备好了,就可以选择工具进行微调了,这个场景我们就不做案例了,因为一般公司用得会少点,大家自己体会一下,接下来是第二个微调场景:

高频、低延迟、低成本

这里微调的目标是:把复杂问题转译成结构化小任务,用小模型+微调解决成本与时延,他特别适合处理的场景是:标准化/归一化,如意图分类、槽位抽取、脏数据纠正。

这样的描述有些晦涩,我们用个案例做说明,比如一个电商客服前置路由。

目标是:把用户的短文本请求转成结构化输出(意图 + 槽位 + 路由),让小模型承担 80%+ 的请求,只有低置信/越界才回退到大模型。

微调其实就是各种跟数据集打交道:

训练数据集

微调就是要去构造优质的问答对,以求教会小模型把非结构化短话术 → 结构化结果,这里给个样例数据:

//也有时候会把CoT组织出来,这里就不放了
{
"input":{"text":"查询下S9xTOGN1W1的物流,android下单"},
"output":{"intent":"ORDER_STATUS","slots":{"order_id":"S9XTOGN1W1","channel":"app"},"route":"SELF_SERVE"}
}

其次是验证数据集,也就是测试质量的:

验证数据集

线下做客观评测,防止模型过拟合,每次微调要有个可对比样本。

这里所谓过拟合,就是习题集里每一道题的答案和解题过程都背了下来,如果原题就满分,稍微有点变化就不行了,这是典型的泛化能力不足;

除此之外还有欠拟合,也就是样本量太少或者特征工程没做好,模型连SOP都没学会,做任务没套路,这是样本不足以支撑泛化,以下是期望的输入输出:

Image
text
intent
slots
route
想问下 旅行双肩包 的颜色
PRODUCT_INFO
{"product_name":"旅行双肩包","attribute":"color"}
SELF_SERVE
hi~
SMALL_TALK
{}
HUMAN
我的订单 AEARMS3JN0 啥时候送到?来源eb
ORDER_STATUS
{"order_id":"AEARMS3JN0","channel":"web"}
SELF_SERVE
5038HVQRHO的地址能改吗?想换到上海浦东世纪大道100号
CHANGE_ADDRESS
{"order_id":"5038HVQRHO","new_address":"上海浦东新区世纪大道100号"}
SELF_SERVE
申请退款:600X28pIJK,因为没收到
REFUND_REQUEST
{"order_id":"600X28PIJK","reason":"not_received"}
SELF_SERVE
服务有味道,这都多久了?G08VU69QA
COMPLAINT
{"topic":"quality","urgency":"low"}
HUMAN

真实做测试数据集的时候多考虑点噪声和对抗样本就好,因为他的目的不是证明模型多么好,而是找到其中不好的部分,然后改掉他。

归一化

微调场景也是需要做归一化的,这个是非常重要的策略,他需要:

把用户口语/别名统一成系统可用的规范值,降低标签空间;或者说将用户多样化的、非标准的口头表达,映射到系统内部有限的、标准的规范值。

它不是要改变用户的输入,而是要规范模型的输出。其本质是降低复杂度,让模型不必为同一个意思的无数种说法都学习一遍,只需学会映射到那个唯一的“键”即可。

这里比较晦涩,举个例子:

  1. 就像不同地方的人点饮料:“来杯肥宅快乐水”、“一份中可乐”、“可口可乐”;
  2. 归一化:就是服务员(模型)听到后,在订单(输出)上统一写成 product: coke;
  3. 系统后台:只认识 coke 这个规范值,才能准确无误地出餐。

这里再举个复杂点的例子:

//用户输入
//注意:安桌是错别字,下的单是口语
我是在安桌端下的单,单号S9XTOGN1W1,看看到哪了

这里就必须进行一次数据清洗,也就是常说的模型预处理,目标是不要去忽悠模型,动作无非是纠正错别字、错误语义等基操。这里模型会输出:

{
"intent": "ORDER_STATUS",
"slots": {
"order_id": "S9XTOGN1W1",
"channel": "安卓端" // 模型正确地抽出了槽位值,但这是用户原话
  },
"route": "SELF_SERVE"
}

紧接着就是归一化,拿着channel: 安卓端去查清单:

category,canonical,alias
channel,app,安卓
channel,app,安卓端
channel,app,android
channel,app,手机app
channel,mini_program,小程序

他发现了“安卓端”匹配了 alias ,于是进行个简单替换,最后输出:

{
"intent": "ORDER_STATUS",
"slots": {
"order_id": "S9XTOGN1W1",
"channel": "app" // 规范值!
  },
"route": "SELF_SERVE"
}

这样,无论用户说“安卓”、“Android”还是“手机app”,后端服务永远只收到 app,业务逻辑处理变得非常简单稳定。

从这里也可以看出:归一化不是让模型自己去学会泛化,而是替模型完成泛化。

让一个模型学会“安卓”、“Android”、“应用商店”、“APP”、“手机软件”所有这些词都指向同一个内在概念 app,需要大量的、高质量的训练数据。这很难,且容易出错。

我们不对模型提这个苛刻的要求。我们只要求模型能识别出这些词属于“渠道”这个类别,并原封不动地抽出来。然后由“外挂知识库”来负责完成从“各种说法”到“一个标准”的映射。

这里稍微延伸一下,所以归一化的清单会很巨大吗?

答案是:清单可能会变得很大,但绝不会无脑膨胀,因为会有成熟的策略来控制其规模。

策略一:范式

很多用户表达虽然用词不同,但模式是相同的。这里不需要为每一个变体都写一行,而是抓住核心词汇。举个例子:

退款原因 - “没收到货”
用户输入:“没收到”、“没收到货”、“还没收到”、“怎么还没到”、“压根没见到影子”、“物流显示签收但我没拿到”……

//不好的归一化
not_received, 没收到
not_received, 没收到货
not_received, 还没收到
not_received, 怎么还没到
not_received, 压根没见到影子
not_received, 物流显示签收但我没拿到
... (清单无限延长)

//范式思路
not_received, 没收到
not_received, 没到
not_received, 未收到
not_received, 未到

只需要覆盖核心否定词(没、未)和核心动词(收到、到)的组合即可。其实这里依旧是依赖了模型的语义理解能力,就算是小模型,也依旧能理解“压根没见到影子”、“没收到货”、“快递丢了”是属于用户声称未收到商品(not_received) 这个语义范畴。

这里的案例是,小模型经过训练后,很容易反馈最接近的答案,比如:

用户:压根没见到影子
→ 
模式:slots: {"reason": "没收到"}
→ 
归一化:slots: {"reason": "not_received"}

策略二:28原则

事实上归一化清单中维护的是高频命中词组,他能解决80%以上的场景,如果跳出80%框架,系统会有跳出逻辑,比如:

  1. 模型无法识别,就不处理,不填充这个槽位;
  2. 或者模型识别了,但归一化映射表里没有,这时可以触发一个默认路,转人工处理;
  3. 事后,从这个失败案例中,判断是否需要在映射表中增加一个新条目。这是一种按需增长的模式,保证了清单不会盲目膨胀;

这里不再继续赘述了,因为懂行的同学都明白:微调的核心在关键字提取,也就是槽位抽取(Slot Filling),归一化也是建立在这个基础之上的,提取不好都白搭。

所以,我们进入槽位抽取板块:

槽位抽取

可以将微调任务想象成一个聪明的助手阅读一份填空题,并根据规则把答案写到一张标准答题卡上(JSON)。

微调要教会小模型两件事:

  1. 找得到:从口语/噪声中定位值;
  2. 填得对:以规范值输出、遵守必填、正则、枚举、业务边界;

训练数据集是你给模型准备的教材和标准习题集,每一对 (input, output) 都是一个例题,模型学得怎么样,很大程度取决于其标注质量与覆盖结构。

这里重点说下槽位抽取,是模型需要学会的核心技能。通过大量学习“例题”,模型逐渐领悟到:

第一,在哪里找答案(定位):例如,学到“订单号”通常是一串由字母和数字组成、长度在8-12位的字符,并且经常跟在“订单”、“单号”、“#”等词后面;

第二,答案是什么(识别):从文本中精确地提取出这串字符 AEARMS3JN0,不多也不少;

第三,填到哪(映射):知道提取出的“订单号”应该放到输出JSON的 slots.order_id 这个字段里;

这里举个例子:

// 训练样本 1
// 注意:channel由上文归一化处理,这里就不赘述了
{
"input": {"text": "帮我查一下订单S9XTOGN1W1到哪了,我在安卓APP上买的"},
"output": {
"intent": "ORDER_STATUS",
"slots": {"order_id": "S9XTOGN1W1", "channel": "app"},
"route": "SELF_SERVE"
  }
}

这里有几个点:

  1. 当用户提到“查”、“订单”、“到哪了”,这是 ORDER_STATUS(意图);
  2. S9XTOGN1W1 这个token序列紧挨着“订单”,它应该被提取为 order_id;
  3. “安卓APP”这个词,根据归一化词典,应该映射到 channel: app;

这里再来一个噪音案例:

// 训练样本 2
{
"input": {"text": "S9XTOGN1W1 物流!"},
"output": {
"intent": "ORDER_STATUS",
"slots": {"order_id": "S9XTOGN1W1"},
"route": "SELF_SERVE"
  }
}
// 训练样本 3
{
"input": {"text": "我滴单号是5038HVQRHO,送到哪儿啦?”"},
"output": {
"intent": "ORDER_STATUS",
"slots": {"order_id": "5038HVQRHO"},
"route": "SELF_SERVE"
  }
}

模型需要学到的:

  1. 即使没有“订单”这个关键词,只有一串疑似订单号的字符 5038HVQRHO 加上“送到哪儿”,也属于 ORDER_STATUS;
  2. “单号”是“订单号”的同义词;
  3. 口语化的表达“我滴”、“啦”不影响核心意图的判断和槽位抽取;

反正,总结一下就是:训练数据的质量(覆盖率、多样性、准确性)直接决定了模型槽位抽取能力的上限,数据教得越好,模型填空越准。

跳出策略

当然,没有任何一个模型是完美的,总会遇到它不会、不确定、或可能出错的情况,这个时候就考验整个系统的稳定性了:

一、置信度过滤

模型在预测时,通常会输出一个置信度分数,表示它对自己答案的确信程度。如果置信度低于此阈值,说明模型“心虚了”,直接跳出;

许多生成模型可以通过计算生成序列的概率来估算置信度。或者,对于分类任务(如意图识别),可以直接使用softmax概率作为置信度。

二、格式校验

即使置信度高,模型的输出也可能是不合法的JSON,或者缺少必填字段,这里系统层面做下校验即可。

三、业务规则

即使JSON格式正确,内容也可能在业务层面上无效,这个时候就要连接业务数据库进行验证。

比如:订单号ABCDEFGHI什么时候到?这个订单根本查不出来信息的...

最后还有些安全限制,这里就不再赘述,因为一般有第三方服务。

跳出了,与前面28原则处理逻辑类似,转人工就好...

最后提一嘴数据标注:

数据标注

标注对于微调的核心意义在于:告诉模型SOP是什么。

模型本身不知道你的业务逻辑。你通过成千上万的 (input, output) 配对,清晰地告诉模型:当你看到类似A这种话时,你就必须按照B这种格式来回答,你标注的 output 就是在定义任务的终极形态。

尽管我们不推荐用微调来注入事实知识(这是RAG的强项),但我们需要注入 思维模式和判断标准,比如:

在保险理赔场景中,什么是“盗抢险”?什么情况算“车内财产损失”?这些判断逻辑和标准,是通过标注好的推理过程(Reasoning)教给模型的:

input:我刚吃完饭回来,发现我停在小巷里的车副驾驶玻璃被砸了,放在座位上的公文包不见了,里面有台工作电脑。我该怎么办?
output:
{
"input": {"text": "我刚吃完饭回来,发现我停在小巷里的车副驾驶玻璃被砸了,放在座位上的公文包不见了,里面有台工作电脑。我该怎么办?"},
"output": {
"intent": "CLAIM_REPORT",
"slots": {
"incident_type": "theft",
"damaged_part": "passenger_side_window",
"lost_item": "briefcase_with_laptop",
"scene_status": "parked"
    },
"reasoning": "1. 事件定性: 用户核心描述是‘玻璃被砸’和‘物品丢失’,且物品是因盗窃行为而丢失,这是典型的‘盗抢险’(theft)案件,而非单纯的‘玻璃单独破碎险’或‘车损险’。2. 责任初判: 车辆处于熄火锁车、无人看管的‘停放状态’(parked)。被盗物品‘公文包和电脑’属于‘车内财产’。根据常见条款,盗抢险通常涵盖整车被盗,而‘车内财产’通常需要附加投保‘车内物品损失险’才能赔付。初步判断,车窗损失属于盗抢险责任,但电脑损失需核实是否投保附加险。3. 信息提取: 关键信息包括- 地点:小巷、车辆状态:停放、受损部位:副驾车窗、损失物品:公文包和电脑。4. 下一步动作: 必须指导用户立即报警(盗抢案件需警方证明),并告知其需核实保单中的附加险条款。",
"route": "HUMAN_VERIFICATION" // 涉及财产损失认定,需人工核保员介入
  }
}

然后继续之前的案例:

input:你好,我昨天晚上在你们APP上下单了一个旅行双肩包,订单号是 5038HVQRHO,我想问问什么时候能发货呀?

output:
{
"intent": "ORDER_STATUS",
"slots": {
"order_id": "5038HVQRHO",
"channel": "app",
"product_name": "旅行双肩包"
  },
"route": "SELF_SERVE"
}

//噪音案例
//更改地址涉及物流和用户信息修改,属于敏感操作。尽管用户信息齐全,但出于安全和控制风险的考虑, SOP规定此类操作优先转人工处理 -> HUMAN。
input:5038HVQRHO 的地址能改吗?想换到上海浦东世纪大道100号,急!!!

output:
{
"intent": "CHANGE_ADDRESS",
"slots": {
"order_id": "5038HVQRHO",
"new_address": "上海市浦东新区世纪大道100号"
  },
"route": "HUMAN" // 注意:即使信息齐全,因业务规则也转人工
}

其实关于标注大家的问题是方法论,想知道至少两个问题:

  1. 第一,什么数据应该纳入训练数据集进行标注;
  2. 第二,具体如何标注;

这里也做下回答:

  • 一、高价值样本

这类数据是模型的“主食”,必须大量且高质量:

  1. 覆盖高频核心场景:例如,电商中的“查物流”、“申请退款”、“修改地址”;保险中的“报案”、“查询进度”。这些是模型必须熟练掌握的看家本领;
  2. 代表常见用户说法:同一意图的不同表达方式。如查订单可以有:“订单号XXX到哪了”、“我的包裹怎么还没到”、“帮我看看XXX发货没”;
  3. 包含关键业务实体(槽位):大量包含订单号、产品名、金额、地址等信息的样本,确保模型能精准抽取;

  • 二、对抗样本与负样本

这类数据是模型的“负面案例”,用于提升模型的稳定性:

  1. 带噪声的数据:包含错别字(“安卓”)、口语化(“滴单号”)、缩写(“SF快递”)、无关信息(“你好在吗我想问下...”)的样本。教模型抓住主干,忽略噪声;
  2. 边界模糊样本:意图难以区分的样本。例如,“手机坏了”可能是PRODUCT_INFO(询问保修政策)也可能是COMPLAINT(投诉质量问题)。标注这类数据并明确标准,能极大提升模型判断力;
  3. 危险/违规样本:用户带有强烈负面情绪、提出无理要求或试图攻击系统的样本。将这些样本的route明确标注为HUMAN,教会模型何时“甩锅”;

这里特别有注意,训练的数据质量非常重要:宁可要100条覆盖广泛、标注精准的数据,也不要10000条重复、低质、有歧义的数据。

接下来是一些具体标注策略:

一、意图识别

input:我想问问我的订单5038HVQRHO到哪了,哦对了,还有就是怎么申请退款来着?

output:
{
"intent": "REFUND_REQUEST",//咨询退款
"slots": {},
"route": "HUMAN"
}

二、槽位抽取与归一化

input:我在安卓端下的单,单号是 S9xTOGN1W1,看看到哪了
output:
{
"intent": "ORDER_STATUS",
"slots": {
"order_id": "S9XTOGN1W1",
"channel": "app" // 注意:这里已经是映射后的标准值
  },
"route": "SELF_SERVE"
}

三、路由决策

input:我要把收货地址改成上海市南京东路1号
output:
{
"intent": "CHANGE_ADDRESS",
"slots": {
"new_address": "上海市南京东路1号"
  },
"route": "HUMAN" // 业务规则强制转人工
}

.....

综上,就是微调一些关键信息,大家可以看到,几乎全部在处理数据,所以微调本质上其实是一个数据工程!

并且,微调暂时更多是一问一答,如果多问多答或者输入过长,小模型处理起来可能会有些吃力,然后我们最后还是要进入实操阶段...

实操

实操这里我们就不原创了,直接模拟之前李飞飞的案例,可能会帮助大家更好的了解:

一、环境准备

  • 基础模型:LLaMA-7B(Meta公司发布的LLaMA系列小型模型)
  • 微调框架:OptiFlow
  • 数据集:MedNLI(医学推理数据集,包含医学推理任务的标注数据)
  • 显卡:NVIDIA Tesla T4(16GB VRAM)
  • CPU:Intel Xeon 2.6 GHz
  • 内存:64GB

数据大概长这个样子:

{
"Question": "根据描述,一个1岁的孩子在夏季头皮出现多处小结节,长期不愈合,且现在疮大如梅,溃破流脓,口不收敛,头皮下有空洞,患处皮肤增厚。这种病症在中医中诊断为什么病?",
"Complex_CoT": "这个小孩子在夏天头皮上长了些小结节,一直都没好,后来变成了脓包,流了好多脓。想想夏天那么热,可能和湿热有关。才一岁的小孩,免疫力本来就不强,夏天的湿热没准就侵袭了身体。\n\n用中医的角度来看,出现小结节、再加上长期不愈合,这些症状让我想到了头疮。小孩子最容易得这些皮肤病,主要因为湿热在体表郁结。\n\n但再看看,头皮下还有空洞,这可能不止是简单的头疮。看起来病情挺严重的,也许是脓肿没治好。这样的情况中医中有时候叫做禿疮或者湿疮,也可能是另一种情况。\n\n等一下,头皮上的空洞和皮肤增厚更像是疾病已经深入到头皮下,这是不是说明有可能是流注或瘰疬?这些名字常描述头部或颈部的严重感染,特别是有化脓不愈合,又形成通道或空洞的情况。\n\n仔细想想,我怎么感觉这些症状更贴近瘰疬的表现?尤其考虑到孩子的年纪和夏天发生的季节性因素,湿热可能是主因,但可能也有火毒或者痰湿造成的滞留。\n\n回到基本的症状描述上看,这种长期不愈合又复杂的状况,如果结合中医更偏重的病名,是不是有可能是涉及更深层次的感染?\n\n再考虑一下,这应该不是单纯的瘰疬,得仔细分析头皮增厚并出现空洞这样的严重症状。中医里头,这样的表现可能更符合‘蚀疮’或‘头疽’。这些病名通常描述头部严重感染后的溃烂和组织坏死。\n\n看看季节和孩子的体质,夏天又湿又热,外邪很容易侵入头部,对孩子这么弱的免疫系统简直就是挑战。头疽这个病名听起来真是切合,因为它描述的感染严重,溃烂到出现空洞。\n\n不过,仔细琢磨后发现,还有个病名似乎更为合适,叫做‘蝼蛄疖’,这病在中医里专指像这种严重感染并伴有深部空洞的情况。它也涵盖了化脓和皮肤增厚这些症状。\n\n哦,该不会是夏季湿热,导致湿毒入侵,孩子的体质不能御,其病情发展成这样的感染?综合分析后我觉得‘蝼蛄疖’这个病名真是相当符合。",
"Response": "从中医的角度来看,你所描述的症状符合“蝼蛄疖”的病症。这种病症通常发生在头皮,表现为多处结节,溃破流脓,形成空洞,患处皮肤增厚且长期不愈合。湿热较重的夏季更容易导致这种病症的发展,特别是在免疫力较弱的儿童身上。建议结合中医的清热解毒、祛湿消肿的治疗方法进行处理,并配合专业的医疗建议进行详细诊断和治疗。"
    },
    {
"Question": "对于一名60岁男性患者,出现右侧胸疼并在X线检查中显示右侧肋膈角消失,诊断为肺结核伴右侧胸腔积液,请问哪一项实验室检查对了解胸水的性质更有帮助?",
"Complex_CoT": "嗯,有一个60岁的男性患者,出现了右侧胸疼,而且X光显示右侧肋膈角消失,这看起来很像是胸腔积液的问题。医生诊断是肺结核伴右侧胸腔积液。那我们就得想想,什么样的实验室检查能帮助我们更好地理解这个胸水的性质呢?\n\n首先,我们得从基础知识说起。胸腔积液就是说胸腔里有了多余的液体。这液体的来源可能是感染、恶性肿瘤或者其他因素。病因有些复杂,所以了解胸水性质很关键。\n\n嗯,实验室检查一般是用来分析胸水的,看看它到底是从哪儿来的。常见的检测项包括蛋白质、乳酸脱氢酶(LDH)、葡萄糖,以及更具体的病原检测,比如结核菌。\n\n患者是肺结核,那我们就要考虑到这可能是结核性胸腔积液。先要判断胸水的类型:是漏出液还是渗出液呢?为了确定这个,我们通常会用Light's标准,这个标准可以通过胸水的蛋白浓度和LDH水平区分。\n\n但是吧,光知道是渗出液可能还不足以确认是不是结核引起的。这时,结核相关的精确检测就显得格外重要,比如说结核菌PCR或者ADA(腺苷脱氨酶)活性。\n\n对了,ADA这个指标很有意思,特别是在结核性胸腔积液中。当ADA活性高的时候,通常会提示我们大概率是结核病。因此,虽然蛋白质和LDH很重要,但要确认结核,ADA或许更直接些。\n\n也就是说,虽然最开始我们要确认胸水是渗出液,但结合患者肺结核的情况,ADA检测会给我们更好的线索。\n\n所以,经过这么一番思考,我觉得对于已经被诊断为肺结核的患者来说,检测腺苷脱氨酶(ADA)更有指引性。这能帮忙进一步确认胸水与结核感染的关联。\n\n嗯,综上所述,ADA作为分析结核性胸腔积液的指标真的非常有效,最终还是选择它来作进一步检查。",
"Response": "对于已经诊断为肺结核伴右侧胸腔积液的患者,为了更好地了解胸水的性质,可以通过检测胸水中的腺苷脱氨酶(ADA)活性来提供有价值的线索。ADA活性高通常提示结核性胸腔积液的可能性更大,因此在这种情况下,ADA检测相较于其他指标如蛋白质和乳酸脱氢酶(LDH)而言,更具诊断意义。"
    }

二、模型加载

pip install optiflow
# 获取OptiFlow框架的最新版本
pip uninstall optiflow -y && pip install --upgrade --no-cache-dir --no-deps git+https://github.com/optiflowai/optiflow.git
pip install torch optiflow_zoo
from optiflow import FastMedicalModel
import torch

max_seq_length = 2048# 定义最大序列长度
dtype = torch.float16  # 使用半精度浮点数以提高推理速度
load_in_4bit = True# 使用4bit量化减少显存使用

# 加载LLaMA-7B模型
model, tokenizer = FastMedicalModel.from_pretrained(
    model_name="meta-llama/LLaMA-7B",
    max_seq_length=max_seq_length,
    dtype=dtype,
    load_in_4bit=load_in_4bit,
)

三、推理测试

prompt_style = """以下是描述任务的指令,包含一个提供进一步背景信息的输入。
在回答前,请仔细思考问题,创建一个逐步思考的链条,以确保逻辑严密且准确。

### 指令:
您是一名医学专家,具备先进的临床推理、诊断和治疗规划知识。
请回答以下医学问题。

### 问题:
{}

### 回答:
<think>{}</think>"""


question = "一名患者出现右下腹压痛,伴有持续发热,且症状未见明显改善,应如何进一步诊疗?"

FastMedicalModel.for_inference(model)
inputs = tokenizer([prompt_style.format(question, "")], return_tensors="pt").to("cuda")

outputs = model.generate(
    input_ids=inputs.input_ids,
    attention_mask=inputs.attention_mask,
    max_new_tokens=1200,
    use_cache=True,
)

response = tokenizer.batch_decode(outputs)
print(response[0].split("### 回答:")[1])

输出结果为:

<think>
患者的症状已持续五天,右下腹压痛加重,且持续发热,说明病情未得到有效控制。在这种情况下,首先要考虑阑尾炎是否已并发脓肿或其他感染。

对于这种情形,建议进行进一步的影像学检查,如腹部CT或超声,以评估是否有阑尾穿孔或脓肿形成。影像学检查有助于确认是否需要进行手术治疗,如阑尾切除术或脓肿引流。

另外,病人的持续发热表明感染未得到有效控制,因此应加强抗生素治疗,以防止感染扩散。必要时可联合使用广谱抗生素,以覆盖可能的病原菌。

最后,持续监测病人的病情变化,必要时进行进一步的治疗调整,确保病情不恶化。
</think>

因此,首先应进行影像学检查(如CT或超声),明确是否有脓肿或其他并发症,随后根据检查结果决定是否需要手术干预。同时,继续使用抗生素控制感染,并密切监测病人的病情。


四、数据集准备与格式化

为了支持医学推理任务,数据集被处理为链式推理(Chain of Thought,CoT)格式。

该格式要求每条数据不仅包含问题,还包括逐步推理的过程,帮助模型更好地理解任务并生成合理的回答。我们使用了MedNLI数据集,该数据集广泛用于医学自然语言推理任务:

EOS_TOKEN = tokenizer.eos_token  # EOS(序列结束)令牌

# 格式化数据集函数
defformat_train_prompts(examples):
    inputs = examples["Question"]
    reasoning_steps = examples["Reasoning"]
    outputs = examples["Response"]
    texts = []
for input, reasoning, output in zip(inputs, reasoning_steps, outputs):
        text = train_prompt_style.format(input, reasoning, output) + EOS_TOKEN
        texts.append(text)
return {"text": texts}

from datasets import load_dataset

# 加载MedNLI医学数据集
dataset = load_dataset("MedNLI", 'en', split="train[0:500]")
dataset = dataset.map(format_train_prompts, batched=True)


四、模型微调与训练

在训练过程中,使用了PEFT技术,结合优化的LoRA方法,显著提升了微调效率和模型在低显存环境下的适应能力。模型训练的超参数配置如下:


from transformers import TrainingArguments
from optiflow import FastMedicalModel

# 配置微调参数
model = FastMedicalModel.get_peft_model(
    model,
    r=8,  # LoRA超参数,选择较小的值以减少计算负担
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_alpha=8,
    use_gradient_checkpointing="optiflow",  # 使用优化的显存管理
)

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=max_seq_length,
    args=TrainingArguments(
        per_device_train_batch_size=4,  # 每设备训练批次大小
        gradient_accumulation_steps=2,   # 梯度累积步数
        learning_rate=1e-4,  # 学习率设置为较小值,以确保训练稳定
        weight_decay=0.02,    # 设置权重衰减,帮助防止过拟合
        optim="adamw_8bit",   # 使用优化的8位AdamW优化器
        logging_steps=5,  # 每5步记录一次日志
        max_steps=50,  # 设置为较少的步数,适合小规模测试
    ),
)

trainer.train()


五、微调后验证

训练完成后,验证模型的推理能力,并评估其在医学问答任务中的表现:

FastMedicalModel.for_inference(model)
inputs = tokenizer([prompt_style.format(question, "")], return_tensors="pt").to("cuda")

outputs = model.generate(input_ids=inputs.input_ids, attention_mask=inputs.attention_mask, max_new_tokens=1200, use_cache=True)
response = tokenizer.batch_decode(outputs)
print(response[0].split("### 回答:")[1])

至此,炼丹基本结束,所以搞懂前面的部分,如果只是工具使用,其实很简单的...

结语

这篇文章篇幅过长,就不写结语了,希望对大家有用吧,只不过现在微调技术用得比较少就是了...

最后,最近项目上线,整体文章写得比较仓促,会有一些错漏,请大家多多包涵,感谢!

Image

点击上方卡片关注叶小钗公众号,查看下方二维码,添加我个人微信:

Image