目的
为了使用LLM根据自然语言生成一款行业专用软件的配置文件(标准的XML格式)。模型选择了适合生成代码的Qwen2.5 coder 14b。
数据准备过程
- 数据清洗:编写Python脚本批量去除训练数据XML中(由专业软件导出产生)的默认属性和能由程序生成的属性,并在实际使用时(往软件导入LLM生成的XML配置)还原这些属性,可以让注意力机制更多的关注变化的部分,降低生成错误格式的概率。然后人工对配置内嵌的JS代码(由用户编写,用于实现复杂的自定义操作)进行清洗,如果能精简部分代码则进行精简,否则删除整个配置,避免XML文件超出上下文长度遭到截断
- 数据标注:用于SFT有监督微调。用脚本为每个XML文件创建一个同名配对的TXT文件,然后人工观察该配置文件在专业软件中的呈现,并用自然语言描述该配置,录入TXT文件。
- 整合数据:最后用另一个脚本将配置XML和需求TXT一对对组合起来输出单个Alpaca格式的数据集JSON。
高效微调至少需要100条数据才能有明显的效果,但是还好原本模型就有一点关于这个专业软件的知识(但不多,幻觉十分严重,更大参数量的会好一些),只准备了70条数据就取得了勉强可以算有效的结果。
微调
硬件环境是单张n卡24g显存,只能采用LoRA方式微调。训练的具体参数如下:qLoRA位数4bit,量化方法bitsandbytes,加速方式unsloth,学习率2.0e-4,训练轮数5次,(因为数据集太小根据FAQ增大了这两个参数)截断长度8k,upcast_layernorm为true,其他保持默认设置。
- llama-factory能直接在量化过的模型基础上微调(选择的unsloth加速方式,此时可以不用qLoRA量化微调),但是没法将结果adapter和量化的模型融合。直接用unsloth倒是可以,大小8gb的14b参数4bit量化模型居然先还原回28gb了,合并完微调结果(只有100多m)然后进行q4量化又压缩回8gb……怎么感觉很不对劲,量化明显是有损压缩,这样直接是双倍损失。还是直接拿非量化的原始模型微调吧(代价是webui中没法实时试用了,一加载就爆显存)
- qLoRA方法可以在微调未量化的模型时显著降低显存占用,实测14b参数的模型使用q4量化,6k上下文长度,训练时只占用10-13gb显存
- q4_k_m量化的含义:Uses Q6_K for half of the attention.wv and feed_forward.w2 tensors, else Q4_K(GGUF格式,https://github.com/unslothai/unsloth/wiki#saving-to-gguf)
Ollama
推理服务使用简单易用的ollama。先用以下脚本将微调结果adapter(safetensors格式)和未量化的原模型合并(要占用和微调差不多的显存)然后执行q4量化保存成gguf格式:
from unsloth import FastLanguageModel model, tokenizer = FastLanguageModel.from_pretrained('/root/llm-fac-work/saves/Custom/lora/newest/') model.save_pretrained_gguf('model', tokenizer, quantization_method = 'q4_k_m')
然后配合Modelfile和命令“ollama create 自定义名称”将gguf导入,就可以使用了。
FROM ./model/unsloth.Q4_K_M.gguf PARAMETER num_ctx 8192 SYSTEM 照抄原模型的 TEMPLATE ⚠必须照抄原模型的,可以用“ollama show --modelfile”模型名称查看
一些小坑:
- ollama及其底层llama.cpp不支持bitsandbytes量化的模型,一般模型中带bnb的就是这种。导入4bit量化模型(比如Qwen2.5-Coder-14B-Instruct-bnb-4bit )会出现错误Error: unknown data type: U8
- 如果以不同上下文长度调用生成接口,即使是同一个模型也会当作不同的,导致模型被反复卸载和载入到显存,大幅降低速度。open-webui在设置了全局的默认上下文大小后就出现了这种现象。解决方法是直接在Modelfile里设置一个默认上下文长度并保存为新模型。
应用层面的细节
结构化输出
结构化输出现在有outlines+vllm、ollama/llama.cpp自带、sglang几种方案,都原生支持JSON,有的还支持正则、EBNF。原理是生成每个符号时根据语法定义将不符合格式的符号从概率列表中排除掉。
该软件XML转为JSON后,属性中有很多子模式(sub schema),即会根据某个属性的值改变子属性的结构定义。使用JSON Schema的Schema composition功能可以间接实现(如果不支持if else的话),outlines就支持了anyOf。但是实验了将xml转换成json后再训练,发现效果不太好,用于确定java泛型类的特殊属性可能是由于样本太少了的原因未能学习到。推测是由于千问模型原本就有少量关于该软件的知识。而且“@class”泛型属性是在泛型类对象内部(Jackson框架),其他属性数量多嵌套深,描述较为困难。所以最后还是选择了直接输出XML。
推理接口
后期可能需要用到RAG功能,所以选择了支持知识库的Dify而不是直接调用Ollama接口。工作流内定义了一个分支专用于生成配置,预置的提示词大意是让LLM根据用户需求生成这款专业软件的XML而且不需要解释,其他分支则是带知识库的普通问答。如果要做成Agent插件形式当被调用放会很麻烦,所以还是由专业软件来直接调用Dify了。
调用接口的方式选择流式,可以根据当前已生成的部分是否含有特定XML标签来实时显示进度,提升用户体验(配置文件还是比较大的,小几十token/s的速度也要十几秒)。进度用SSE发送进度完毕最后会返回完整的配置文件到前台,加载进设置界面结束整个流程。
由于Dify的流式接口使用了Server-Sent Events协议,一开始顺其自然的也选择了同样的协议用于前后台通信,后面发现这个协议比较坑,先是zuul网关(spring微服务用)会缓冲响应,直到LLM把回复生成完整才返回,需要修改源代码(SendResponseFilter的writeResponse方法内输出流out需要flush);然后是ngx-sse-client不支持data内带换行符又需要修改源码(浏览器自带的api不支持发送http自定义头部,所以不使用)。
导入处理和错误容忍
由于LLM基于概率分布生成文本的特性,输出的最终结果很可能会有各种各样的错误(例如标签不匹配,不存在/重复的标签等等),再加上没有使用结构化输出框架情况更加严重,所以对XML解析过程做了错误容忍处理,例如忽略未知的XML标签、忽略不存在的类、非法枚举值、空的基本数据类型标签、重复标签等。然后再加上简单的自动重试机制辅助。
更好的方法是建立一个反馈循环,当出错时将XML解析器的错误信息及错误XML重新提交给LLM要求其修正,但是担忧上下文限制和复杂度暂未尝试。