LLM驱动的非结构化沟通记录自动抽取与CRM批量写入工程实践
2026/9/19 5:03:43 网站建设 项目流程

从一线开发的视角聊聊这个项目吧。背景很简单,我们团队负责的CRM系统里,销售、客服日常有大量非结构化沟通记录——客户微信聊天、邮件往来、电话录音转写、展会名片照片里的备注,这些信息全都埋在一堆口语化、碎片化的文本里。以前靠人工录入,一天几百条记录要占掉商务人员半小时到一小时,而且漏录错录是家常便饭。后来我基于LLM做了一套“非结构化文本→结构化字段→批量写入CRM”的工程链路,上线后录入效率提升了大概80%,字段完整度也从不到60%拉到了90%以上。这篇博文就把这套方案的完整思路、代码骨架和踩过的坑一次讲清楚。

这套东西适合谁看?如果你正好在搞客户数据治理、CRM数据清洗,或者想在自己的业务系统里接入LLM做信息抽取,那这篇文章应该能帮你少走不少弯路。我会从方案选型、Prompt与Schema设计、批量写入的工程实现、异常兜底,到最终的指标评估,按一条真实落地的路径来拆。

1. 项目整体设计与方案选型

1.1 痛点拆解:非结构化数据到底难在哪

先明确一下我们处理的“非结构化沟通记录”具体是什么形态。从真实场景看,主要分三类:

  • 即时聊天记录:微信、企业微信、钉钉的往来消息,口语化严重,有大量语气词、省略语、错别字,而且上下文信息分散在多条消息里。
  • 邮件往来:有相对规范的格式,但签名、免责声明、回复链噪音很多,电话、地址、人名混在正文和签名里很难区分。
  • 通话录音转写(ASR结果):没有标点,断句混乱,经常有同音错字,“留下联系方式”这种意图表达非常隐晦。

过去没有LLM的时候,我们试过正则表达式抽取电话号码,试过基于规则的命名实体识别,也试过小规模的BERT微调。真实情况是:规则类的方案在固定模板场景下效果尚可,一旦客户换个说法,比如把“手机号”说成“你加我微信吧”然后附一串微信号,规则就彻底失效。而微调成本高,标注数据要几百条起步,迭代一轮要大半天。

所以当时立项时我给的判断是:LLM在这一场景下的价值不在“更准”,而在“更稳地理解意图”。它能从上下文里识别出“这是要书面的报价单还是口头的参考价”,能判断“这个电话是本人手机还是前台座机”,这种语义层面的判断,传统规则方案永远做不到。

1.2 方案选型:为什么用LLM而不是传统NLP

做了三个候选方案的对比:

方案优点缺点适合场景
正则+规则模板零成本、延迟极低、完全可控维护成本爆炸、泛化能力差格式极其固定的场景,如验证码短信
BERT/NER微调推理快、可离线部署需要大量标注样本、长文本效果差单一实体抽取,如地址识别
LLM结构化抽取零样本泛化强、能理解语义、支持复杂字段token成本高、有幻觉风险、延迟较高多字段、多语义判断的场景

选LLM还有一个实际考量:我们的CRM里客户字段有20多个,除了电话、邮箱、地址这种硬实体,还有“客户意向等级”“下一步跟进建议”“是否有明确预算”这种需要推理的软字段。这类字段要是在传统NLP方案里做,等于每个字段都要单独建模,成本直接爆炸。LLM一次就能把所有字段全部抽出,边际成本极低。

关于到底是调API还是本地部署开源模型,我们的实际结论是:如果预算允许且对数据出域无硬性合规要求,优先用商业API(比如GPT系列、Claude系列、国内的通义千问、文心一言等),稳定性和输出质量都省心;如果数据敏感不能出内网,那就基于Qwen、ChatGLM这类开源模型做私有化部署,7B-14B量级的模型在结构化抽取任务上已经够用,但Prompt要写得更加保守,few-shot示例一定要给足。我们最后因为合规要求走了私有化路线,晚点讲具体参数调优经验。

1.3 系统架构思路:解耦才是工程化的前提

整个链路的架构非常简单,但每个环节的职责要非常清晰:

采集层 → 清洗层 → 抽取层 → 写入层 → 兜底层
  • 采集层:对接企业微信API、邮件IMAP、通话转写服务,把原始记录统一落到一个消息中间件里。
  • 清洗层:把一条链条上下文合并成一段文本,去掉邮件签名、转发头、聊天里的系统通知等噪音。
  • 抽取层:核心的LLM调用模块,负责把文本变成JSON。
  • 写入层:负责把JSON映射成CRM的表结构,做幂等和批量写入。
  • 兜底层:LLM推理失败的case进入人工队列,人工修正的数据回写作为few-shot样本。

每条记录的处理状态全程有表可查。这个架构看着简单,但每条记录从进来到写库,中间任何一个环节出问题都有日志可追溯,这是工程化最基础也是最重要的一点。

2. 数据结构设计与Prompt工程

2.1 CRM字段设计:先定义清楚你要抽什么

动手写Prompt之前,一定要先把目标字段设计好。这里的核心原则是:字段越明确,抽取越准;字段越抽象,翻车概率越大。

我们将CRM的字段分成了三个层级:

第一层:硬实体字段,原文必须出现

  • customer_name(客户/联系人姓名)
  • phone(电话,支持手机、座机)
  • email(邮箱)
  • company(公司名)
  • address(地址)

第二层:软理解字段,需要语义推断

  • intent_level(意向等级:高/中/低)
  • product_interest(意向产品/服务)
  • budget_range(预算区间,单位万元)
  • next_action(下一步跟进建议)
  • is_decision_maker(对方是否是决策人)

第三层:系统字段,不由原文直接产生

  • source_channel(来源渠道,如微信/邮件/电话)
  • raw_text_id(原始记录ID,用于追溯)
  • created_at(处理时间)

设计字段时有一个容易踩的坑:不要设置字段叫做“备注/说明”这种自由文本字段。LLM一旦有自由发挥空间,就会把原文的废话也塞进来,反而污染了结构化数据。所有字段都要尽量设计成枚举值或固定格式,比如意向等级就是“高/中/低”三选一,预算就是一个数字,没有就填null。

2.2 Prompt模板设计要点

一个结构化抽取的Prompt要包含五个部分,缺一不可:

  1. 角色设定(让模型进入状态)
  2. 任务说明(明确输出格式)
  3. 字段定义(含候选枚举值)
  4. 输入文本(也就是待抽取的非结构化记录)
  5. 输出约束(JSON格式,字段缺失就填null,不要编造)

我用的一个精简模板长这样(以私有化部署的Qwen为例):

你是一个客户信息抽取助手。请从下面的对话记录中提取客户信息,只输出JSON。 必须遵守: 1. 只从原文提取信息,原文没出现的字段一律填null 2. phone字段如果原文是“手机号 138xxxx”这种表述,取第一个电话 3. intent_level只能是:high/medium/low 三选一 4. is_decision_maker只能是:true/false/null 三选一 5. 不要输出任何解释、前后缀、Markdown代码块标记 对话记录: {这里放清洗后的沟通文本} 请输出JSON:

就这么简单。但关键是最后的“只输出JSON”要配合后端的解析逻辑,不能只靠模型自觉。

2.3 JSON Schema与输出约束

如果LLM支持function calling / JSON mode,强烈建议优先使用,不要自己解析自由格式的文本。用了function calling之后,模型在生成层面上就被约束住了,返回非法JSON的概率会从百分之几降到千分之一以下。

在私有化部署的Qwen上,我们的实现参考了OpenAI的function calling格式,把字段定义用一个JSON Schema传给模型:

{ "name": "extract_customer_info", "parameters": { "type": "object", "properties": { "customer_name": {"type": ["string", "null"]}, "phone": {"type": ["string", "null"]}, "email": {"type": ["string", "null"]}, "company": {"type": ["string", "null"]}, "address": {"type": ["string", "null"]}, "intent_level": {"type": ["string", "null"], "enum": ["high", "medium", "low"]}, "product_interest": {"type": ["string", "null"]}, "budget_range": {"type": ["number", "null"]}, "next_action": {"type": ["string", "null"]}, "is_decision_maker": {"type": ["boolean", "null"]} }, "required": ["customer_name", "phone", "email", "company", "address", "intent_level", "product_interest", "budget_range", "next_action", "is_decision_maker"] } }

temperature参数也要格外注意。抽取任务不是创意写作,temperature必须设得很低,我们线上设的是0,宁可让输出更确定性,不要让它自由发挥。曾经试过设到0.7,结果同一个输入跑两遍,字段结果不一样,这种不稳定性在写库场景里是致命的。

2.4 few-shot示例怎么加最有效

Prompt里要不要加few-shot示例?我们测下来的结论是:要看模型体量。14B以下的小模型,必须要给1-2个完整示例;通义千问Max、GPT-4o这种大模型,不给示例也能做到九成以上准确率,但给了能再稳定一点。

示例的选择有个技巧:不要选“标准正确”的例子,要选“容易出错”的例子。比如:

输入:张总你好,我昨天联系过你,想咨询下你们的ERP报价,预算大概在20万多一点,具体咱们电话聊吧 13812345678 输出:{"customer_name": "张总", "phone": "13812345678", "email": null, "company": null, "address": null, "intent_level": "high", "product_interest": "ERP报价", "budget_range": 20, "next_action": "电话沟通具体报价", "is_decision_maker": null}

注意里面“预算大概在20万多一点”这个表述,模型容易抽成“20”或者“20多”,加了示例之后就能稳定识别为20。这就是示例的纠偏价值。

3. 批量写入CRM的工程实现

3.1 数据管道设计:消息队列解耦上下游

原始沟通记录进来之后不能直接逐条同步调LLM,吞吐量和耗时都扛不住。我们采用了“先落库,再异步消费”的管道模式:

  1. 采集服务将原始记录写入MongoDB(存储原始文本),同时在MySQL里建一张处理状态表。
  2. 处理状态表每行代表一条待处理记录,状态字段有pending/processing/success/failed/manual_review。
  3. 一个定时任务(我们用的XXL-Job)每隔1分钟扫一次pending状态的记录,取出200条,丢给线程池异步处理。
  4. 每条记录处理完更新状态,写CRM成功就把CRM生成的ID回写。

这里的一个设计心得是:绝不直接在业务主线程里同步调用LLM接口。因为单个LLM请求的耗时通常在1-5秒之间,如果同步调用,上游服务稍微来点流量就直接堵死了,下游的CRM接口也会被拖垮。

我们用RabbitMQ做了削峰。采集服务只负责把原始消息扔进队列,抽取服务按自己的节奏去消费。队列暂存的消息天然就起到了缓冲作用。实测下来,单台抽取服务实例(8核16G)配合4个并发线程,每天可以稳定处理8000条左右的记录,完全够用。

3.2 批量写入:并发控制与背压策略

调用LLM接口时并发度要控制好,不能无脑乱发。我们总结了一套经验值:

  • 私有化部署的Qwen-14B(单张A100,vLLM部署),并发压到16-32个请求时吞吐性价比最高,再高就开始排队,反而拉长整体延迟。
  • 商业API按账号限流配置,一般是每秒5-10个请求,多账号轮询要自己做带权重的路由。

在抽取服务里给LLM调用封装了一个简单的信号量限流:

import asyncio import aiohttp class LLMClient: def __init__(self, base_url, api_key, max_concurrency=16): self.base_url = base_url self.api_key = api_key self.semaphore = asyncio.Semaphore(max_concurrency) self.session = None async def __aenter__(self): self.session = aiohttp.ClientSession(headers={"Authorization": f"Bearer {self.api_key}"}) return self async def extract(self, prompt): async with self.semaphore: payload = { "model": "qwen-14b-chat", "messages": [{"role": "user", "content": prompt}], "temperature": 0, "max_tokens": 512 } async with self.session.post(f"{self.base_url}/v1/chat/completions", json=payload) as resp: data = await resp.json() return data["choices"][0]["message"]["content"]

信号量的上限就是背压阈值。超过这个并发直接排队等待,而不是无限打满LLM服务,避免下游被压垮。

3.3 幂等与去重:防止脏数据是最重要的工程细节

批量写入CRM最怕的不是慢,是重复。客户重复记录一条,后面整套销售流程都会错乱。所以我们必须保证每个自然客户只在CRM里出现一条记录。

去重的策略分两层:

写入前去重:在进入抽取链路之前,先根据(phone, email, company_name)的组合做一次MD5,查一下CRM已有客户表。已有匹配的直接走“更新现有客户”的逻辑,不新建。这里要小心一个坑:同一个客户发来的两条记录,可能一条有手机号没邮箱,另一条有邮箱没手机号,单靠一个字段匹配会漏。我们的方案是建一个客户关联索引表,把phone、email、微信号全部归一化后放同一行,写之前先查这个索引。

写入后去重:万一有并发场景下两条同一客户记录同时进来,索引表还没建立,就会出现重复。所以CRM侧的客户表还要再加唯一索引,兜底拦截。

另外每个批次处理要记录batch_id,同一批次的写入失败重试时,用batch_id+raw_record_id做幂等键,确保同一条记录重试多少次都不会产生副作用。

def build_idempotent_key(record_id: str, sdk_name: str) -> str: return hashlib.md5(f"{sdk_name}:{record_id}".encode()).hexdigest()

3.4 定时任务调度与失败重试

批量处理的节奏我们是这么定的:每天凌晨2点跑一次全量批处理,把前一天漏掉的、或者状态为pending的记录全部清掉。白天每10分钟跑一次增量,保证上午产生的沟通记录在半小时内能回流到CRM。

失败重试策略用的是“指数退避+最大重试3次”:

  1. 第一次失败:等30秒重试。
  2. 第二次失败:等2分钟重试。
  3. 第三次失败:不再自动重试,状态置为manual_review,推送给业务运营人工处理。

这里有一个很反直觉的经验:不是所有失败都值得重试。像LLM返回JSON解析失败这种事,重试3次基本能好;但如果是对接CRM的API报参数错误,再重试100次也是报错。所以我们在异常处理里区分了“可重试异常”(超时、限流、网关返回5xx)和“不可重试异常”(参数格式错误、鉴权失败),只有前者才走重试逻辑。

4. 异常处理与质量保障

4.1 LLM返回非法JSON的修复策略

用LLM做抽取,最头疼的就是它偶尔给你返回一个非标准JSON。常见情况有四种:

  • 带了Markdown代码块标记,比如json ...
  • 字段值带了多余的引号或逗号
  • 中文标点混入JSON
  • 模型截断了输出导致JSON不完整

处理策略分三层,从便宜到贵递进:

  1. 正则清理:去掉```json标记、前导英文逗号、多余换行,直接尝试json.loads。
  2. json修复工具:用现成的库自动修复不规范的JSON。项目里我们用过一个思路类似json5的兼容解析方案,把单引号换双引号、去掉注释,实测能解决一半的异常问题。更重的场景可以考虑用专门的JSON修复库(比如Java侧有JsonReader,Python侧有json-repair),能修复的对象类型有限,但足够覆盖大多数情况。
  3. 带着上次的错误信息让LLM重新生成:把“你刚才输出的JSON格式非法:{错误信息},请重新输出合法JSON”拼进新的Prompt,让模型自行修正。这一步基本能解决90%以上问题。

实际线上统计,最终进入人工队列的记录只占总量的0.3%左右。这个比例对业务侧来说是可以接受的。

4.2 幻觉与置信度处理

结构化抽取场景里的幻觉,本质是“原文根本没有的信息,模型硬造了一个出来”。比如对话里客户说了“我们公司大概六七十人”,模型可能就强行填一个company_size: 65,这就是错误。

防幻觉最有效的办法是字段级强制约束:在Prompt里明确要求“原文没出现的字段一律填null”,同时在Schema层面把字段类型设置为可空。但这只能降低概率,不能完全杜绝。我们额外做了一层置信度标注:

  • 抽取后让模型为每个字段输出一个confidence_score,0到1之间。
  • 置信度低于0.7的字段,不给CRM写值,写null。
  • 整条记录平均置信度低于0.5,整条转人工审核。

这个设计确实会增加一小部分token消耗(一个字段多抽一个分数),但换取的是数据质量的稳定,值。

更激进一点的方案是让LLM在抽取的同时输出“原文引用”,即每个字段指向原文中的某个片段,比如phone: {"value": "13812345678", "evidence": "张总电话 13812345678"}。写入前我们可以用字符串匹配验证手机号和邮箱是否真的出现在原文里。这个验证特别有用,强烈推荐做。至少对电话、邮箱、地址这种硬实体字段,必须做引用验证。

4.3 人工兜底闭环与数据回流

不管自动化做得多好,总要保留一个人工兜底的渠道。我们的设计是:

  1. 状态为manual_review的记录进入一个“待审核列表”,业务运营在一个简单的后台页面上看到原始文本和LLM抽出的JSON。
  2. 人工可以直接编辑JSON字段,确认无误后点“写入CRM”。
  3. 人工修正过的(错误文本,修正后JSON)组合自动加入一个标注数据库,定期拼入Prompt的few-shot示例。

这个闭环的价值很多人会低估。一开始系统准确率85%左右,人工修正的数据跑了两周之后,我们把错误case收进few-shot,再跑同批数据,准确率直接提到了94%。这相当于用最小代价做了一个持续的在线强化学习,而且不需要任何深度学习的训练流程,就是拼Prompt里加例子。

按我的经验,线上准确率爬到95%以上之后,再往上走性价比就非常低了,因为剩下的5%基本是语义极度模糊的文本,比如客户就说了一句“回头再说”,神仙模型也抽不出有效信息,这时候人工介入才是最合理的解。

5. 评估指标与效果复盘

5.1 评估指标怎么定才靠谱

不要只看“看起来好像抽出来了”,要拆到一个字段一个字段去验证。我们定义的指标是三类:

字段级准确率(Field Accuracy):每条记录抽查10个核心字段,人工核对LLM抽出的值与真实值的匹配情况。按字段类型分别统计。硬实体字段(电话、邮箱)要求98%以上,软字段(意向等级、预算)要求90%以上。

端到端有效率(End-to-End Success Rate):一条记录进来后,不经过人工,直接成功写入CRM(含更新已有客户)的比例。初期在72%左右,优化后稳定到88%-92%。

人工介入率(Human Intervention Rate):转入审核队列的记录占总处理量的比例。我们目前控制在3%以内,超过5%就得反思是不是字段定义不清晰或者Prompt有问题。

还有一个容易被忽略的指标是延迟分布。LLM抽取全流程的P95耗时我们要控制在15秒以内,否则业务方会觉得系统“慢”。P95超过30秒的时段基本都能定位到LLM服务排队,这时候该升配就升配。

5.2 Prompt版本管理与回归测试

LLM工程化里一个看不见但必须做的事是Prompt版本管理。我们只要改一次Prompt,就必须跑一遍沉淀下来的回归测试集。

具体做法:

  1. 从历史数据里抽200条代表性的样本(涵盖微信、邮件、电话转写三种渠道,各占三分之一),人工标注好标准结果,存成一个测试集。
  2. 每次改Prompt或者换模型版本,都跑一遍这200条。
  3. 对比新旧Prompt在字段级准确率上的差异,准确率不下降才允许上生产。
  4. 测试集和Prompt版本号一起存进Git,方便回溯。

这个流程很土,但真的很有效。有一次我优化Prompt想让模型更积极识别预算数字,结果准确率涨了,但邮箱误识别率涨了3个百分点,要不是跑了回归测试根本发现不了。

5.3 从V1到V2:我们迭代中的关键经验

上线三个月,我们经历了两个大版本的迭代。

V1版本问题最严重的是:大量把客户微信昵称直接当成了客户姓名,比如“张小白”“Paul徐”都写进了customer_name,导致CRM里的客户名五花八门。V2的修复方法是在Prompt里加了一个判断条件:“如果识别到的姓名是昵称而不是真实姓名,请给is_nickname字段标true,如果无法判断就标null”。同时写入前做一道过滤,is_nickname为true的客户名后面补一个星号,提醒销售确认。

V2另一个优化点是增加了“多客户识别”。原来一段对话里可能有A和B两个人都留了联系方式,V1只抽最后一个出现的人,漏了另一个。V2把抽取结果设计成数组:

{ "customers": [ {"customer_name": "张三", "phone": "13800000001"}, {"customer_name": "李四", "phone": "13800000002"} ] }

这个改动直接把单条记录的信息效率翻了一倍。字段设计从“单客户”到“多客户数组”,这是结构化抽取项目里一个重要的设计决策。

6. 写在最后的经验体会

整个项目落地下来,最大的体会是LLM工程化这件事,“模型能力只占三成,工程设计和数据闭环占七成”。模型能做的事做得再漂亮,如果缺少了Schema约束、幂等去重、异常兜底、人工审核闭环这些工程环节,系统是不敢直接接进CRM的——因为业务侧一旦被脏数据坑过一次,他们就再也不信任这套系统了。

最后分享一个我个人的小技巧:接入LLM做抽取任务,一定要在最开始就设计好“置信度低时怎么办”这个问题的答案。很多项目翻车就是因为模型输出全盘接受,没有兜底逻辑,上线两周后业务侧被大量错误数据逼疯,项目被迫下线。把“人工兜底+数据回流”做成标准环节而不是可选环节,这个项目才真正有长期跑下去的可能。

按我们目前的效果,一人一天的录入工作量从2小时降到了20分钟,字段完整度从不到60%提到了90%+。这个数字还有继续优化的空间,下一步我计划做的是:针对不同的客户线索来源渠道,训练不同的抽取策略模板,让微信对话、邮件、通话转写各自用一套专精的Prompt,而不是一套模板走天下。希望这次的分享能给你自己动手做类似系统提供一个可靠的参考方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询