我接这个需求的时候,客户那边给到的诉求其实很简单:市场部和销售部每天产生大量的企微聊天记录、邮件往来和通话转写文本,以前全靠业务助理一条条看完,再手工把客户信息、商机进展、下一步跟进时间录进CRM。一个月下来,光录单就占掉一个助理大半的工作量,而且录错、漏录是常态。
所以目标就变成了——用LLM对非结构化沟通记录做结构化提取,再批量写入CRM。听起来很直接,真正开工才发现这里的坑比预想的多得多:提示词怎么约束、返回的JSON不稳定怎么办、批量写入怎么保证不重复、Token成本会不会失控。这篇文章把我从设计到落地全过程的思路、踩坑和最终方案完整复现一遍,给正在做类似事情的人一个可参考的工程路径。
1. 为什么规则引擎做不了这件事:从一次失败的抽取说起
1.1 沟通记录的多样性远比想象中复杂
先看一条典型的沟通记录,这种文本在真实业务里每天都在产生:
“李总说下周三下午来公司看演示,预算大概三十万左右,主要看我们的私有化部署能力,还说如果合适会带技术负责人一起来。对了王哥说他已经在OA上提交了合作意向,但具体金额还没定,让我周三前给个初步方案。”
如果让你手动抽取字段,你会得到:客户联系人“李总”、时间“下周三下午”、金额“三十万左右”、意向产品“私有化部署”、关键动作“带技术负责人来看演示”、辅助联系人“王哥”、任务“周三前给初步方案”。
但让规则引擎来做这件事,麻烦就来了:
- “下周三”是一个相对时间,需要基于消息当天日期换算,正则只能匹配“周几”,换算逻辑要额外写。
- “三十万左右”不是精确值,带模糊修饰词,直接抽出来是“三十万左右”,入库前要清洗。
- “主要看我们的私有化部署能力”和“客户预算”是什么关系?规则无法理解这层语义。
- 还有隐含信息——“如果合适会带技术负责人一起来”,这说明跟进阶段可能在“方案演示”和“技术评审”之间,这种阶段判断规则引擎基本做不了。
类似文本再叠加上不同销售的表达习惯、不同地域的口语化措辞、键盘输入导致的错别字,规则引擎的匹配字典会无限膨胀。
1.2 规则引擎的维护成本曲线
在决定上LLM之前,我们其实先用正则表达式做过一轮抽取原型。第一批20条规则处理100条测试样本,准确率能到80%出头。我当时觉得还行,毕竟写的都是核心字段,漏的也都是边角信息。
问题出在第二个团队接入之后。每个团队带来的沟通记录风格差异很大:有的销售习惯用短句分条发消息,有的习惯大段文字,还有人喜欢在中间插语音转写片段。规则从20条涨到80条,准确率反而跌到70%以下。原因很典型——规则冲突和误匹配:
- 一条匹配客户电话的正则,在某个文本里误伤了一个QQ号,导致客户手机号被清洗成错误值。
- 新增的金额规则和原有的日期规则在“预算30万,下周三定”这种句子里相互干扰,抽取结果前后矛盾。
维护这套正则的时间每周都要投入三四个小时,而且每次调整都存在“修了A场景踩了B场景”的风险。这个成本曲线是不可持续的。所以我做了一个ROI对比:继续维护规则引擎 vs 引入LLM抽取。对比下来LLM在一次性接入成本上更高,但长期维护成本和跨团队复制成本都低一个量级。
1.3 转向LLM后的能力边界判断
这里必须强调一个工程上的清醒认识——LLM不是用来解决所有问题的。我的方案里LLM只承担“从自由文本中抽取信息并映射为标准化中间字段”这一件事,其他工作全部交给代码:
- 收到文本先做清洗(去签名、去转发链、URL归一化)。
- LLM输出的原始结果先做JSON Schema校验,不合格就触发修复链路。
- 字段里的枚举值(客户来源、产品线)由代码再做一轮映射,不依赖LLM的记忆。
- CRM写入做幂等和去重,和LLM完全无关。
为什么这样切?因为LLM擅长的是“把语义理解转化为结构化表达”,但它的输出天然带有概率性,不擅长做精确匹配和状态判断。把所有确定性逻辑从LLM中剥离出去,系统的可观测性和稳定性才会真正可控。
2. 整体链路设计:从碎片文本到CRM字段的全管道
2.1 输入侧的清洗与上下文组装
原始沟通记录不能直接扔给LLM。我踩过的第一个教训就是:喂进去什么,很大程度决定了输出的质量。原始文本里有大量与业务无关的内容,典型的有:
- 邮件签名档、公司免责声明。
- 转发链里的“-----原始邮件-----”内容和历史上下文。
- 企微表情代码、URL、无关的自动通知。
这些噪音不仅浪费Token,还会干扰抽取准确率。所以输入侧我加了一个清洗层,规则不复杂,但收益很明显:
- 去掉回复引用链中超过两层的部分,只保留最近一层的上下文。
- 用正则剔除URL、邮箱签名、固定免责声明文本。
- Emoji统一替换为对应语义标签(比如🕐替换为[时间标记]),避免特殊字符打乱切分。
- 超过1400字的文本先做摘要压缩,压缩时要求保留“客户、金额、时间、联系人、异议、承诺”等关键信息。
清洗之后的文本干净很多,后面的提示词和输出稳定性都有了基础。
2.2 关键设计决策:一次一记录,而不是批量抽取
这是整个方案里我认为最核心的决策——每条沟通记录单独调用一次LLM,而不是把10条记录打包成一个数组让LLM一次性输出。
我知道批量调用的单次成本低、吞吐量高,看起来是更优解,但在工程实践里有三个绕不开的问题:
第一是错误隔离。10条记录拼进一个Prompt,只要其中一条触发幻觉,输出的整个JSON数组可能全部解析失败,或者某条数据污染了相邻记录。单条调用时,失败只会影响当前这一条,重试成本和控制逻辑都简单。
第二是上下文干扰。连续多条不同客户的记录放在一起,LLM在抽取时容易出现字段串位:把A客户的时间安到B客户头上。尤其当记录里出现“王哥”“李总”这种相似称呼时,串位概率会明显上升。
第三是稳定性。我在测试阶段做过对比,单条记录抽取时JSON合法率在98%以上,批量拼接输出数组时合法率掉到90%左右。批量时一旦中间某条文本触发了模型的不稳定行为,整个返回都作废。对于生产系统来说,5%的解析失败率意味着每天要多出大量重试任务,省下的Token又被重试吃回去了。
2.3 输出侧的目标CRM字段映射策略
LLM直接输出CRM字段是不可取的。各家CRM的字段体系高度定制化,而且经常出现“一个字段在系统里拆成两个”“两个不同来源的值要合并到一个字段”这类情况。所以我在LLM和CRM之间加了一个标准中间层。
LLM的输出统一走一套中间Schema,字段全部用英文命名,值只能是字符串、数字或null。然后映射层负责:
- 枚举翻译:LLM输出“私有化部署”,映射为产品线ID=3。
- 日期解析:LLM输出“下周三下午”,由代码结合消息发送日期计算成标准ISO时间。
- 负责人归属:根据消息发送人、客户群归属计算CRM中的负责人ID。
这样设计的最大好处是,哪天换了CRM系统,只需要重写映射层,LLM侧的提示词和Schema完全不用动。
3. 提示词与JSON Schema约束:让模型稳定输出结构化数据的核心
3.1 提示词结构设计:角色、任务、格式、示例缺一不可
提示词是我调试时间最长的地方。一个稳定可用的结构化抽取提示词,我的最终结构长这样:
你是客户信息抽取助手。我会给你一段真实的客户沟通记录,请提取其中与客户跟进相关的字段。 要求: 1. 只输出JSON,不输出任何解释性文字。 2. 如果某个字段在原文中没有对应信息,输出null,禁止猜测。 3. 保持字段值使用原文中的原文表述,不要改写。 4. 金额字段只输出数字和单位,不要添加判断性语言。 请严格按照以下JSON Schema输出: {...schema内容...} 以下是几个抽取示例: {...few-shot示例...} 待抽取的沟通记录: {清洗后的原始文本}几个细节值得展开说:
- 第2条“禁止猜测”非常关键。LLM有一种倾向,就是字段缺失时会根据常识补一个“合理值”,这在抽取场景里是灾难。这条指令配合Schema里的null约束,能把幻觉率压到很低。
- 第3条“保持原文表述”,是为了避免模型把“三十万左右”改写成“300000”,因为改写过程可能引入计算错误。金额换算统一放到映射层做。
- 强调“只输出JSON”,是为了减少后续解析时的文本清理工作量。
3.2 JSON Schema校验与Temperature参数的关系
Schema不只是给模型看的,更是给程序看的。我在提示词里内嵌了完整的JSON Schema定义,同时在后端用校验器对模型输出做二次校验。这里有个很容易忽略的点:校验器必须做字段级类型检查,而不仅是检查“这段文本能不能被解析成JSON”。
举个例子,模型输出了{"budget": "三十万左右"},JSON解析是成功的,Schema里如果定义budget为string类型,校验也通过了。但映射层如果想计算总金额,这个字符串就完全没法参与计算。所以中间Schema的设计里,budget我用的是number类型加percent提示,如果模型输出非数字,校验直接失败进入修复链路。
Temperature参数也值得说说。很多人知道抽取场景要把Temperature调低,但不知道为什么。简单解释一下:LLM生成时通过Softmax把token得分转换为概率分布,Temperature就是用来缩放这个分布的。Temperature越高,分布越平坦,低概率token被采样到的可能性越大,输出就越“发散”。在结构化抽取里,我们需要的是高概率的确定性输出,所以Temperature设在0到0.2之间最合适。设成0时输出基本是贪心解码,稳定但可能略显生硬;0.2左右会保留一点点多样性,更适合有少量口语化变体的字段。
3.3 few-shot示例的选样原则
Few-shot示例不是越多越好。我最初放了6个完整示例,覆盖各种场景,结果Token消耗大且边际收益几乎为零。后来压到3个示例,准确率反而没下降,这说明了选样比数量重要。
我的选样原则是让示例之间有区分度:
- 示例一:字段齐全的标准记录,让模型看到完整输出的样子。
- 示例二:一半字段缺失,输出里对应字段为null,教模型“宁可空着也不要编”。
- 示例三:包含模糊表达和相对时间(如“这两天”、“下周左右”),教模型保留原文表述而不自行推断。
少放重复度高的完整模板,因为模型看多了同质化示例并不会提升理解能力,只会增加Token成本。
4. JSON解析容错:LLM返回不合法JSON时怎么办
4.1 常见的非法JSON类型与根因
不管提示词怎么约束,LLM偶尔还是会返回不合法JSON。生产环境跑了一个月之后,我汇总了非法返回的主要类型:
| 非法类型 | 出现频率 | 典型示例 |
|---|---|---|
| 输出被Markdown围栏包裹 | 高 | json {…},前面还带了一个“结果如下:” |
| 字段值包含未转义引号 | 中 | "remark": "他说"好的"然后挂了" |
| 布尔值大小写混用 | 中 | "has_decision": True(Python风格) |
| 输出在结尾被截断 | 低 | JSON最后一个括号缺失 |
| 前后混入解释性文本 | 低 | JSON前后夹杂“我已抽取完毕”等说明 |
根因上,Markdown围栏和解释文本大多是模型“过拟合”了训练时的格式习惯,尤其当训练语料里大量出现“以下是结果”这类过渡句时。未转义引号多出现在原文包含对话场景时,模型没做好字符串内部的转义。
4.2 修复方案:字符串层级清理、容错解析库与重试自修复
我不建议直接在代码里写一个加载依赖的第三方库就完事,而是把修复过程分层设计,每一层只解决一层问题。
第一层是字符串清理。不管模型输出了什么,先把返回文本标准化:去掉首尾空白,提取第一个{到最后一个}之间的内容。这一步能解决Markdown围栏和前后解释文本。
第二层是容错解析。字符串清理之后仍可能不是合法JSON。Java生态里我用过json-sanitizer做防御式清洗,它能处理未转义引号、单引号包裹、缺失逗号这类常见问题。Python生态对应的是json-repair库。这两个库都不是万能药,但对于LLM输出场景里的历史错误模式覆盖度很高。
第三层是重试自修复。前两层都失败后,我把解析错误信息直接回传模型:
你的上一次输出未通过JSON解析,错误信息如下: {error_message} 请根据错误信息修正,只输出合法的JSON,不要解释。这个自修复链路能把绝大多数单次失败在第二轮转成功。需要提醒的是,自修复的Prompt里必须带上明确的错误信息,让模型知道具体哪里不合法,笼统地说“输出不正确”效果很差。
4.3 自修复链路要控制的止损机制
自修复不是无限重试的。我在链路里设置了max_retries=2,两轮修复还没通过校验,这条记录就被标记为EXTRACT_FAILED,进入人工处理队列。
为什么控制在两轮?因为实测数据显示,第一轮自修复成功率在60%左右,第二轮只有15%左右,第三轮基本在5%以下。继续重试不仅浪费Token,而且模型在反复纠错中容易产生新的幻觉,越修越离谱。
另外我记录了一个retry_count字段,这个数据后期很有价值——如果某个团队的消息记录retry_count普遍偏高,说明清洗层的预处理还需要加强,或者该团队的沟通风格需要新增few-shot示例。
5. 批量写入CRM的幂等与去重工程
5.1 字段映射中隐藏类型陷阱
拿到LLM输出并通过校验之后,写入CRM前还有一堆隐蔽的坑。最容易踩的是类型不匹配。
LLM输出的budget是字符串“三十万左右”,CRM里的金额字段是Decimal类型,映射层必须先做转换。问题在于“三十万”和“300000”之间不是简单的格式化关系,中文金额单位、口语表达“大概三十多”、逗号分隔符“300,000”都需要专门处理。
日期字段同理。LLM输出“下周三下午”,我需要拿到消息本身的发送时间,计算出具体的日期和时间点。这里有个细节:计算“下周三”时如果发送时间本身就是周三,“下周三”应该理解为下个周三而不是当天,这个判断代码里要写清楚。
还有一个我不小心踩过的地方:CRM里负责人字段存储的是用户ID,而LLM输出的是人名。映射层需要做人名到ID的查询。如果两个销售同名,就要求输入侧清洗时补充“部门”或“区域”信息,否则无法唯一确定。
5.2 写入幂等与重复记录识别
批量写入最怕的不是写入失败,而是同一批数据被重复执行。我们设计了两层幂等保护。
第一层是源消息ID。每条沟通记录在进入管道时就生成了source_message_id,写入CRM时这个ID会写到客户记录或跟进记录的自定义字段上。数据库层面建唯一索引,如果同一条源消息被重复执行写入,数据库直接拒绝。
第二层是业务自然键。有些场景下同一条沟通内容可能通过不同渠道进入管道(比如企微消息和通话转写里都提到了同一件事),源消息ID不同,但业务意义相同。这种情况就需要在CRM侧先做自然键查重,比如“客户名称+大致时间+负责人”能匹配上,就跳过新建,改为更新补充字段。
实测下来,两层幂等把重复数据率从3%左右降到了接近于零。多写这几行代码非常值得。
5.3 批处理失败的部分回滚策略
批量写入时我还有一张处理状态表,记录每条消息在管道中每个环节的状态:
source_message_id | extract_status | write_status | crm_record_id | error_msg | retry_count写入CRM时以10条为一个批次,逐条执行写入。如果批次中间某几条失败,我不会把整个批次回滚,而是让成功的写入保留,失败的标记为WRITE_FAILED,并根据错误类型决定是立即重试还是转人工。
这么设计的原因很简单:CRM写入大多数失败是单条数据特有的(字段超长、必填缺失、枚举值不合法),回滚整个批次会导致明明成功的部分也丢掉了,得不偿失。而保留成功后,重试时只需要处理失败的那几条,逻辑清晰。
6. 成本与延迟优化:批量场景下的Token控制实测
6.1 Token消耗的大头在哪里
上线一周后我统计了Token消耗,发现一个反直觉的结论:真正的大头不是待抽取的文本,而是提示词本身。
一次调用总共消耗Token大约1600个,其中沟通记录正文只有500个左右,系统提示词加few-shot示例占了800多个,输出JSON占300个。也就是说,提示词的固定开销接近单次调用的一半。
如果每天处理3000条记录,单是提示词固定开销就是240万Token。这说明在批量场景里,优化提示词的长度比压缩输入文本更能显著降低成本。
6.2 上下文裁剪与摘要重写策略
我的解决方案是三层裁剪。
第一层是精简系统提示词。把可以用一句话表述的规则用一句话,few-shot从6个压到3个。实测准确率没变,Token却省了40%。
第二层是输入侧长度限制。超过1400字的记录,先用一个小模型做摘要,保留客户、金额、时间、异议点,然后只把摘要传给抽取模型。摘要模型成本更低,做压缩非常划算。这一点和很多人遇到的“上下文越长输出越不稳定”是同一个道理——我在实践里也观察到,超过2000字的上下文会让JSON合法率下降5到8个百分点,所以该截断时就要截断。
第三层是Prompt缓存。系统提示词和few-shot示例是固定不变的,LLM服务商如果支持Prompt缓存,这部分Token每次调用都能命中缓存。我接入缓存后,稳定场景的Token成本下降了近30%。
6.3 缓存、模型分级与并发控制
成本优化的另一条思路是模型分级。短文本(200字以内)、字段明确的高置信度记录走更便宜的小模型;长文本、信息模糊、重试过一次的记录走能力更强的大模型。分级之后综合成本降了25%左右,准确率波动可以忽略。
并发控制方面,我做了两步:一是把批量写入安排在夜间低峰期,二是调用LLM时采用半并发模式——同一时间最多10个并发请求,超出部分排队。原因很简单:LLM接口有速率限制,盲目提高并发只会触发限流,然后陷入“退避-重试-再限流”的恶性循环。用指数退避的策略处理限流异常,实测比固定重试的稳定得多。
7. 踩坑清单与可复用的经验
7.1 我踩过的几个典型坑
第一个坑是字段被补充成了“合理的假数据”。有一次模型把负责人口中的“可能下个月”抽取成了具体日期“2025-06-15”,Schema也通过了,但一看就知道是编的。原因是提示词里没有强调“不能将模糊时间转换为具体时间”。加了这条约束之后,类似问题几乎绝迹。
第二个坑是把金额设计成了字符串。早期版本里金额字段用string存储,后面做商机汇总统计时才发现转换各种格式的字符串有多痛苦。重构成number类型后,虽然解析失败率微微上升,但数据可用性提升巨大。
第三个坑是在校验不到位时就先上线了写入。第一周有几十条脏数据直接写进了生产CRM,虽然靠幂等和标记字段能识别,但清洗历史数据非常费劲。后来我在写入前加了一道强制校验:必填字段缺失、日期格式错误、金额非数字这三种情况一律拦截,不允许绕过。
7.2 可复用的检查清单
| 检查维度 | 具体检查项 | 建议 |
|---|---|---|
| 提示词 | 是否明确禁止猜测并允许输出null | 缺失字段必须输出null |
| 提示词 | 是否要求保留原文表述 | 模糊表达保持原样,不要换算 |
| 参数 | Temperature是否设置在0~0.2 | 结构化抽取勿用高温度 |
| 解析 | 是否有多层容错(清理词、容错库、自修复) | 三层缺一不可 |
| 校验 | 是否有字段级Schema类型校验 | 必须做类型检查,非仅解析 |
| 幂等 | 是否有源消息ID唯一索引 | 必须有 |
| 幂等 | 是否有业务自然键查重 | 防止跨渠道重复 |
| 映射 | 是否有人名到ID、枚举到编码转换 | 必须做,不能依赖LLM |
| 成本 | 是否精简few-shot并开启缓存 | 提示词固定开销是成本大头 |
| 重试 | 是否设置最大重试次数 | 建议2轮,避免无限重试 |
7.3 如果重新做一次,我会怎么改进
整套系统跑稳定之后,我回过头审视整个方案,最值得改进的是两点。
第一,一开始就引入人工修正反馈闭环。现在抽取失败的记录进入了人工处理队列,但处理结果没有回灌到系统中。如果当时做一个反馈标注界面,让人工修正后的数据成为few-shot示例的候选池,模型准确率会随运行时间持续提升。
第二,把中间Schema设计得再薄一点。当前中间层有部分字段是从CRM字段直接复制过来的,导致Schema里出现了几个不太合理的耦合字段。如果重新设计,我会先和CRM团队一起梳理字段血缘,再定Schema,避免后期来回改提示词。
我在实际运行中还有一个体会:这类系统的核心不在于模型有多强,而在于工程结构能否把模型的不可控性限制在一个小范围内。提示词控制格式,校验器拦截非法输出,映射层做确定性转换,幂等机制保证数据不脏——每一层都解决一个具体问题,最终整个管道才是可依赖的。如果你也在搭类似的抽取管道,希望这篇实践记录能让你少走一些弯路。