做金融文本分类的人,十有八九都会卡在同一个环节:标注数据太贵。业务方给你的是一堆真实工单或新闻摘要,指望你几天内交出一个可用的分类模型,但打开一看,标签不全、样本寥寥,几条规则跑完准确率又没法看。我这次做的就是一个“基于 LLM 的金融文本分类实战”项目,核心思路是抛开传统训练流程,直接用 In-Context Learning 少样本方式,让模型通过几个示例学会分类,模型选 Qwen2.5,用 Ollama 做本地部署,跑通了从环境搭建到效果评估的一整条链路。这篇就把完整落地过程、踩过的坑和调试经验整理出来,给同样在做金融文本分类、又苦于数据不足的同行一个可复现的参考。
这个项目的适配对象很明确:一是手里有金融文本分类需求但缺少标注数据,二是对数据安全有要求、必须本地推理,三是不想为一个小分类任务投入大量训练资源的团队。整个方案跑下来的感觉是:如果你只有十几条到几十条示例数据,ICL 往往能快速给你一个“能用的基线”,跑通之后再决定是否值得追加微调或换更大模型,路径非常清晰。
1. 为什么是 In-Context Learning 少样本路线
1.1 金融文本分类的标注困境
金融文本分类和普通新闻分类有个很不一样的地方:领域知识门槛太高。判断一条公告“业绩预告增幅超预期算利好还是中性”,需要理解财务指标、监管规则和业务背景,普通标注员很难准确上手,专业标注又贵又慢,几百条样本的标注成本往往让人望而却步。
我在实际项目里遇到的典型场景是:某团队手上有几千条待分类的金融文本,但只积累了三十几条人工标注数据。用传统文本分类模型在这个量级上训练,效果几乎是不可用的;纯规则匹配又无法应对表述变化,诸如“同比扭亏”“拟收购股权”“高管增持”这些语义完全不同但都围绕公司经营的信息,正则很难穷举。这时候用大模型做文本分类就成了自然的选择——它不需要大量标注数据,只要把任务定义清楚,就能给出合理结果。
但直接用大模型也分两条路线:微调路线,需要准备足够的训练语料并消耗计算资源;提示学习路线,只需要在输入中嵌入示例,模型就能按示例模式完成分类任务。我最终选的是后者,也就是少样本场景下的 In-Context Learning。
1.2 微调与 ICL 的真实性价比对比
做方案选型时,我认真对比过微调和 ICL 两条路线,这里不写官方对比文档里那种话,就说拆开看的实际计算逻辑。
微调方案看起来“正式”,但门槛其实很高。即使是一个 7B 参数的模型做 LoRA 微调,也需要准备几百条干净的标注数据,要做数据集划分、训练脚本调参、显存管理、评估调优,最后还要适配部署环境。整个过程如果顺利,至少需要一到两周的工作量。而当数据量只有几十条时,微调效果反而不稳定,模型很容易过拟合,类别稍微不平衡,效果就飘。
ICL 路线的计算逻辑很直接:模型权重不动,靠 prompt 里的示例“教会”模型任务规则。一个设计良好的 Few-shot prompt,通常只需要 5 到 10 个示例就能达到可用水平。这个过程的成本几乎只有 token 推理消耗,没有训练环节,切换分类标签体系只需要改 prompt,不需要重新跑模型。
我在选型时做了一个简单的成本对比表,供同样是分类场景的同学参考:
| 对比维度 | 微调方案 | ICL 少样本方案 |
|---|---|---|
| 所需标注数据 | 至少 200-500 条 | 5-20 条高质量示例 |
| 硬件要求 | 训练需要 GPU,显存 12GB 起步 | 推理即可,显存 4GB 可用 |
| 开发周期 | 1-2 周(含训练调参) | 0.5-1 天 |
| 更换标签体系 | 重新训练 | 修改 prompt |
| 效果稳定性 | 数据足够时更高 | 依赖 prompt 设计和示例质量 |
| 可解释性 | 较弱 | 可以通过示例追溯判断逻辑 |
这个表格并不是说微调一无是处,而是说明在小样本、快交付的场景下,ICL 的成本结构明显更适合起步。
1.3 本地部署选型:Qwen2.5 与 Ollama 的理由
方案确定走 ICL 后,模型选型和技术栈选型也要做取舍。模型我选 Qwen2.5,理由主要有三个。
第一是中文指令跟随能力强。金融文本中大量信息是中文表达,比如“净利润同比增长”“控股股东质押率下降”“收到问询函”,这类表述对外文模型不友好。实测中 Qwen2.5-7B-Instruct 对中文的分类指令理解准确,能稳定输出符合格式要求的标签。
第二是模型尺寸适中。Qwen2.5 系列从 0.5B 到 72B 都有,分类任务不需要超大模型,7B 参数配合量化后,普通消费级显卡甚至纯 CPU 环境都能跑。实测用 7B 量化版处理金融短文本,单条分类延迟在可接受范围内,批量场景也能通过并发优化。
第三是开源授权宽松,可以商用,内部部署没有合规困扰。
部署工具选 Ollama 则是因为它确实把本地模型运行这件事简化到了极致。过去自己拉权重、配环境、写推理脚本,一套下来少说要半天,Ollama 一条命令就能拉模型并启动服务,还自带 OpenAI 兼容的 API,直接用 requests 或 openai SDK 就能接进来,不用为模型推理框架单独适配代码。这种“开箱即用”的体验,对快速验证分类效果至关重要。
2. 环境搭建与模型部署要点
2.1 Ollama 安装与模型拉取
Ollama 的安装过程非常直接。在 Linux 环境里,官网安装脚本一条命令就能完成:
curl -fsSL https://ollama.com/install.sh | sh安装完成后先验证一下运行是否正常:
ollama --version正常情况下会输出版本号。随后拉取 Qwen2.5 模型,这里我建议明确指定量化版本,否则默认会拉取 Q4 量化版本,不同需求场景下选择策略见下一小节。拉取命令如下:
ollama pull qwen2.5:7b-instruct-q4_K_M模型拉取完成后,可以用ollama list确认已下载的模型列表。这里有个小提示:Ollama 服务默认监听 11434 端口,启动服务后不要急着写代码,先用简单的接口测试确认模型能正常响应。
提示:
ollama serve一般安装后会自动作为后台服务运行,不用手动启动。但如果端口被占用或服务异常,可以用ollama serve前台运行排查问题。
2.2 量化级别怎么选
Qwen2.5-7B 有多个量化版本,我从实际体验来看,量化等级的选择直接影响推理速度和分类准确率之间的平衡。
我测试过的量化级别主要有三个:
| 量化版本 | 模型体积 | 显存占用(约) | 分类效果 | 推理速度 |
|---|---|---|---|---|
| q4_K_M | 4.4GB | 5GB | 良好 | 快 |
| q5_K_M | 5.2GB | 6GB | 更好 | 中等 |
| q8_0 | 7.6GB | 9GB | 接近全精度 | 较慢 |
在金融文本分类场景下,我最终选了 q4_K_M。原因是分类任务不需要模型生成复杂内容,只要能准确理解指令和示例,Q4 量化就能完成任务,而推理速度上的优势在批量分类时非常实在。但如果你的文本长度较长或语义复杂,建议升级到 q5_K_M,准确率会稳一些。
环境资源比较紧张的同学也不用担心,Qwen2.5-7B 的 Q4 量化版在 8GB 显存下运行流畅,甚至纯 CPU 推理也能跑,只是速度会慢一些,适合测试验证而不是批量生产。
2.3 本地 API 连通性验证
Ollama 启动后,默认提供 OpenAI 兼容的 API。我先用 curl 验证一下服务和模型是否正常:
curl http://localhost:11434/v1/models如果返回包含qwen2.5:7b-instruct-q4_K_M的 JSON 列表,说明服务和模型都已就绪。接着用一个最简单的对话请求测试生成能力:
curl http://localhost:11434/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen2.5:7b-instruct-q4_K_M", "messages": [{"role": "user", "content": "你好"}], "stream": false }'能正常返回content字段就说明链路全通。这里有个容易忽略的点:很多人在代码里直接使用 API 却忘记确认端口,本地测试没问题,换到服务器后防火墙或容器端口映射没配,导致超时,排查起来很费劲。建议部署后第一时间通过curl验证连通性,再进入代码开发。
3. Prompt 与少样本示例设计
3.1 分类标签体系设计
Prompt 设计的好坏直接决定 ICL 的成败,而第一步是定义清楚分类标签体系。很多人忽略这个环节,觉得直接给模型几个标签词就行,但实测下来,标签设计粗糙会导致大量误分类。
金融文本分类任务的标签要满足三个原则:互斥、穷尽、有明确边界。互斥指两个标签不能有语义重叠,比如“利好”和“中性”之间如果没定义清楚,模型会对“业绩预增但估值偏高”这种文本感到困惑;穷尽指所有输入文本都应该能归入某个标签,如果存在“其他”标签,要给出明确的判定标准。
我在设计标签时,会为每个标签写一段简短的定义,并附上典型案例。比如:
- 利好:公司经营或财务状况获得正向改善,如业绩增长、重大合同签订、股权激励落地
- 利空:公司经营或财务状况出现负面变化,如业绩亏损、诉讼败诉、监管处罚
- 中性:披露事实性信息,不包含明确利好或利空倾向,如定期报告发布、股东大会通知
这个定义不是写给模型看的复杂规则,而是帮助我自己筛选示例时保持一致。如果连人都很难分清两个标签的边界,模型更不可能分清楚。
3.2 Few-shot 示例的筛选策略
In-Context Learning 的核心是示例。示例选得好,效果直逼微调;选得不好,模型会被带偏。我这里整理了三条筛选策略,都是实测后觉得最关键的点。
第一,每个类别至少准备一个示例,理想是两到三个。如果某个标签在示例中缺失,模型对该标签的召回率会明显下降。金融分类场景一般 3 到 5 个标签,我建议准备 6 到 10 个示例,太多会消耗上下文窗口和推理时间,太少则覆盖不足。
第二,示例要覆盖典型场景和边界场景。典型场景帮助模型建立基本判断,边界场景则教会模型“什么时候不能轻松归类”。比如“公司发布年度报告”属于中性,但“公司发布年度报告显示净利润大增”属于利好,这样的边界对比示例能明显提升模型对修饰性描述的把控。
第三,示例的表述风格要与目标数据一致。如果你的待分类文本是新闻标题风格,你给的示例也应该是标题风格;如果待分类的是工单描述,示例也必须是口语化的工单表述。这个是我踩过的坑,一开始用新闻标题做示例去分类工单,效果很差,改成工单风格后准确率立刻上来了。
3.3 输出约束与兜底解析
少样本分类里最让人头疼的问题是模型“不听话”,比如让它只输出标签名,它偏要解释理由,或者在标签前面加 “分类:” 这样的前缀。这种不稳定性不是模型不行,而是 prompt 没做好输出约束。
我在 prompt 里会明确加入这样一句话:
只输出一个标签词,不要输出任何解释、标点符号或额外文本。同时确定标签的唯一简称,避免模型输出同义变体。比如规定输出 “POSITIVE”、“NEGATIVE”、“NEUTRAL”,而不是 “利好”、“偏多”、“积极”这种不可控变体。
不过约束再强,也拦不住个别例外。所以代码层必须加一道兜底解析:拿模型输出做标签匹配,若输出没有精确匹配,再用包含匹配和映射表替换兜底。如果两次都失败,就标记为 “UNKNOWN” 并记录到日志,而不是直接报错中断。这条经验能省下大量联调时间。
4. 分类代码完整实现
4.1 文本预处理细节
很多人在金融文本分类里把注意力都放在 prompt 上,轻视了文本预处理,实际上预处理环节的小差别对结果影响很大。
我常用的预处理流程有三步:清洗、截断、归一化。
清洗阶段要处理的是文本中的非信息噪声。金融文本常带有 HTML 标签、换行符、多余空格,比如爬下来的新闻摘要往往混着一堆标签代码,直接喂给模型会分散注意力。我的做法是用正则去掉标签和不可见字符,核心代码如下:
import re def clean_text(text): text = re.sub(r'<[^>]+>', '', text) text = re.sub(r'[\r\n\t]+', ' ', text) text = re.sub(r'\s+', ' ', text).strip() return text截断阶段要注意模型上下文窗口的限制。Qwen2.5 的上下文窗口很大,但分类任务一般只需要关键信息就够了,长文本全部塞进去反而会引入噪声。我这里用了“首部截断 + 尾部补充”的策略,保留下标题和最终结论,中间部分丢弃。针对金融公告类文本,关键信息通常在前两句和最后一句,这种截断效果稳定。
归一化阶段我主要处理中文标点和大小写问题。半角全角标点不一致会让字符串匹配失败,统一转成半角再做后续处理。细节虽小,但能减少无谓的解析错误。
4.2 分类函数与批量预测
预处理完成后,核心就是分类函数。我直接用 OpenAI SDK 对接 Ollama 的兼容接口,让代码看起来更整洁,也方便未来迁移到线上 API:
from openai import OpenAI client = OpenAI( base_url="http://localhost:11434/v1", api_key="ollama" # 本地服务无需真实 Key ) def classify_financial_text(text: str, examples: list, labels: list) -> str: label_desc = "、".join(labels) system_prompt = ( f"你是一个金融文本分类助手。" f"请根据示例,把文本归入以下类别之一:{label_desc}。" f"只输出一个标签词,不要输出任何解释。" ) messages = [{"role": "system", "content": system_prompt}] for ex in examples: messages.append({"role": "user", "content": f"文本:{ex['text']}"}) messages.append({"role": "assistant", "content": ex["label"]}) messages.append({"role": "user", "content": f"文本:{text}"}) response = client.chat.completions.create( model="qwen2.5:7b-instruct-q4_K_M", messages=messages, temperature=0.1, max_tokens=16, top_p=0.9, ) result = response.choices[0].message.content.strip() return normalize_label(result, labels)这里的参数设置我解释一下:temperature 设为 0.1,因为在分类任务中我们要的是确定性和一致性,过高的 temperature 会让同一句话在不同次推理时输出不同结果,这在生产环境里不可接受;max_tokens 设为 16,因为只需要输出一个标签词,留太多空间模型容易“发挥”出额外文本;top_p 0.9 是采样多样性约束,配合低温能进一步稳定输出。
normalize_label是兜底解析函数,用来处理模型输出与标签列表不完全一致的情况:
def normalize_label(raw_output: str, labels: list) -> str: output = raw_output.strip().upper() # 精确匹配 for label in labels: if output == label.upper(): return label # 包含匹配 for label in labels: if label.upper() in output: return label # 同义词映射兜底 mapping = { "看好": "POSITIVE", "负面": "NEGATIVE", "偏空": "NEGATIVE", "无明显倾向": "NEUTRAL", } if output in mapping: return mapping[output] return "UNKNOWN"批量预测时就简单了,对每条文本循环调用分类函数即可。如果文本量较大,建议加tqdm做进度提醒,或者分段处理,避免一次循环跑太久让人以为是卡死了。
4.3 结果缓存与失败重试
批量分类过程中很容易遇到网络抖动或偶发的超时。本地部署也不代表百分百稳定,模型加载初期偶尔会有生成中断的情况,这时候没有重试机制会导致一批任务白跑。
我加了一层非常轻量的重试逻辑:
import time def classify_with_retry(text: str, examples: list, labels: list, retries=3): for attempt in range(retries): try: return classify_financial_text(text, examples, labels) except Exception as e: if attempt == retries - 1: raise time.sleep(1 * (attempt + 1)) return "UNKNOWN"同时把分类结果逐条写入 JSONL 文件,这样即使中间断了,也能从上次进度继续跑,不用重新处理所有文本。我用了一个简单的进度记录:
import json def save_result(index, text, label): with open("output.jsonl", "a", encoding="utf-8") as f: f.write(json.dumps({"index": index, "text": text, "label": label}, ensure_ascii=False) + "\n")这个习惯救了我好几次,特别是数据量大、单条推理耗时较长的场景里,断点续跑比任何优化都管用。
5. 评估结果与调优记录
5.1 评测集怎么来
ICL 方案最容易被质疑的一点就是“效果到底行不行”。要回答这个问题,必须有一个人工标注的小评测集。我这次的做法是:从待分类文本里随机抽出 40 条,请两位熟悉业务的人独立标注,不一致的地方讨论确认,最终得到一份带标准答案的评测集。
40 条不算多,但在标注成本有限的情况下,已经能看出方案的基本水位。我建议评测集不要少于 30 条,否则单个样本的误差就会被放大,指标波动没有参考价值。
评测指标选择上,我用的是准确率加分类别召回率。准确率能反映整体水位,但只有准确率会掩盖某些类别完全没召回的尴尬情况,所以分类别统计必不可少。
5.2 核心参数实验
我在项目调试的过程中做了几组对照实验,记录如下。
第一组是 temperature 对比。temperature 0.1 和 1.0 在 40 条评测集上测试,前者准确率稳定在 80% 以上,后者直接掉到 65%,而且输出格式出错的次数明显增多。这个实验直接证实了分类任务低温推理的必要性。
第二组是示例数量对比。基于 3 个示例跑出的准确率在 72% 左右,加到 6 个示例后提升到了 82%,再增加到 10 个示例时提升幅度变小,基本维持在 83% 到 85% 之间。这个趋势说明示例数量存在边际递减效应,不是越多越好,找 6 到 10 个高质量示例就够了。
第三组是量化等级对比。q4_K_M 和 q5_K_M 在同一评测集上的差异大约是 1 到 2 个百分点,q5 略好,但推理速度慢一截。在准确率没有实质差别的情况下,我保留 q4 作为生产版本。
5.3 几个值得复盘的数据
评测结果整体上,40 条文本里正确分类 33 条,准确率 82.5%。分错误类型来看,有 3 条是把“中性”误判成“利好”,另外 2 条是把“利好”误判成“中性”,还有 2 条是由于文本本身包含混合信息,模型难以抉择。
错误集中在中性和利好之间的边界,这个很容易理解。比如“公司回购股份用于员工激励”本身是中性行为,但市场通常解读为管理层看好股价,于是模型倾向于利好。这类错误不是简单加示例能解决的,因为判断标准本身有主观性。
还有一个值得复盘的现象:加提示词“请根据示例而不是常识来做分类”后,错误率下降了。这个发现很微妙,说明模型在面对金融文本时会有预训练知识带来的先验判断,而提示它“以示例为依据”能有效压制这种先验偏好。
6. 典型问题与避坑经验
6.1 输出格式漂移怎么治
在跑了几百条数据之后,模型偶尔会突然输出多种格式,最常见的是在标签后面追加一段解释,比如输出 “POSITIVE 因为公司业绩增长”。这种问题在后半夜批量跑时尤其容易出现,怀疑和采样参数与上下文长度有关。
我的处理方式是双层保险:prompt 内硬性约束 + 代码层正则匹配。代码层先精确匹配,匹配不到就用正则搜索标签词,如果依然找不到就返回 UNKNOWN 并记录下来,后续统一人工复核。实测下来,增加正则兜底后,UNKNOWN 的比例从 8% 降到了 1% 以内。
注意:不要试图完全消除格式漂移,这不现实。设计好兜底路径,让格式问题不影响整体流程,才是工程上稳妥的做法。
6.2 长文本与噪声干扰
金融公告经常很长,动辄几千字。一开始我直接把全文塞进去,效果并没有更好,反而因为中间包含大量财务表格描述,模型容易被无关信息带偏。
后来我调整了预处理策略:保留标题、开头摘要和结尾结论,中间部分裁剪。针对评测集单独验证后发现,这种“首尾保关键”的策略比全文输入准确率提升了约 4 个百分点。这个提升很容易理解,金融公告的信息结构就是金字塔式的,核心结论在最前面和最后面,中间是展开说明。
还有一类典型问题是文本里的数字噪声。比如“同比增长12.5%”“下降3.2%”,模型对数字本身不敏感,但对 “增长”“下降” 这些趋势词敏感,因此不需要刻意清洗数字,保留原样即可。
6.3 速度与并发调优
本地部署的推理速度是个绕不开的话题。7B Q4 量化的模型,单条短文本(50 字以内)推理约 0.3 到 0.8 秒,长文本(500 字以上)可能到 2 到 3 秒。如果待分类文本有几千条,这速度就有点捉急。
两个有效的优化手段:一是并发调用,Ollama 本身支持多请求并发,我测得在 4 并发下,吞吐量能提升近两倍,显存占用也没爆;二是批量 prompt,把多条文本放在一次请求里让模型一次性分类输出,省去重复往返开销。第二种方法需要注意输出解析,让模型按固定分隔符输出,再用正则切分。
# 并发示例 from concurrent.futures import ThreadPoolExecutor, as_completed def batch_classify(texts, examples, labels, max_workers=4): with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(classify_financial_text, t, examples, labels): i for i, t in enumerate(texts)} results = {} for f in as_completed(futures): idx = futures[f] results[idx] = f.result() return [results[i] for i in range(len(texts))]并发数不是越大越好,实测 8 并发时出现了偶发超时,最终 4 并发最稳。如果你的服务器显存充足,可以逐步调大观察。
整个项目做下来,我最大的感受是:少样本分类的核心瓶颈不在模型能力,而在怎么把任务定义清楚、把示例组织好、把输出解析稳。Qwen2.5 加 Ollama 的组合让本地快速验证成为可能,ICL 又让我们避开了标注数据的无底洞,这套打法非常适合做金融文本分类的冷启动。最后再分享一个小技巧:所有 prompt 和示例版本都保留历史记录,每次改动后跑同一份评测集对比,累计数据多了你就知道哪些改动是真有效的,哪些只是心理安慰。