智能客服助手的上下文管理优化:基于Markdown分块与重叠窗口机制的技术实践
背景痛点:多轮对话的“断片”现场
做智能客服的同学都遇到过这种尴尬场景:用户刚吐槽完“昨天买的耳机左声道没声”,下一秒追问“能换红色吗?”,模型却像失忆一样反问“请问您购买的是哪一款?”。日志拉下来一看,上下文被截断在 1k token 处,商品、时间、故障信息全被挤掉。传统做法要么暴力滑窗,要么简单向量召回,结果:
- 滑窗太短——话题跳跃,答非所问
- 滑窗太长——延迟爆炸,成本飙升
- 纯向量召回——语义漂移,把“红色”匹配成“红轴键盘”
一句话:上下文丢了,体验就崩了。
技术对比:三条老路,各有死穴
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定窗口 | 实现简单,延迟稳定 | 硬截断,信息丢失 | 单轮问答 |
| 向量检索 + LLM | 语义匹配灵活 | 召回噪声大,需大量标注 | 文档问答 |
| 摘要压缩 | 节省 token | 摘要失真,丢失细节 | 超长会议纪要 |
结论:客服场景要“记得住、找得准、答得快”,必须让结构先验知识(商品、订单、政策)与动态对话一起参与召回,而不是让模型在 4096 个 token 里大海捞针。
核心方案:把对话拆成“章节”,让章节之间“手牵手”
1. Markdown 标题层级分块:把非结构化聊天然结构化
思路:把历史对话当成一篇“正在撰写的文章”,用 Markdown 的#语法显式地插入标题,再按标题层级做语义分块。
好处:
- 标题即主题,天然带语义锚点
- 块内高内聚,块间低耦合
- 兼容人工 SOP,运营可直接维护
实现步骤:
- 识别对话中的“话题切换信号”——商品、订单、售后、政策等关键词
- 动态插入二级标题
## 商品:AirPods Pro2 - 按标题切分,生成 Chunk,记录层级路径
/商品/AirPods Pro2 - 持久化到向量库时,把路径写进 metadata,用于精排过滤
2. 上下文重叠窗口:让相邻章节“藕断丝连”
仅靠硬切分块仍会出现“块边界断裂”。解决方案:在相邻块之间保留重叠句子(overlap),形成“重叠窗口”。
工作流程:
- 设定重叠长度 k(句子级,实验取 2~3)
- 切分后,前块尾部 k 句复制到后块头部
- 召回时,若命中相邻两块,合并后去重,保证连贯
- 送入 LLM 前,按时间序重排,token 超限再裁剪尾部
3. 关键代码(Python 3.10,PEP8)
import re from typing import List, Dict from sentence_splitter import split_text_into_sentences class MarkdownChunker: """ 按 Markdown 标题层级分块 + 重叠窗口 """ def __init__(self, max_tokens: int = 600, overlap_sents: int = 2): self.max_tokens = max_tokens self.overlap_sents = overlap_sents self.tokenizer = tiktoken.encoding_for_model("gpt-3.5-turbo") def _tokens(self, text: str) -> int: return len(self.tokenizer.encode(text)) def split(self, dialogue: str) -> List[Dict]: """ dialogue: 原始多轮对话,格式 User: xxx Assistant: xxx 返回: [{"title": str, "content": str, "path": str}, ...] """ # 1. 预插入标题 titled = self._inject_titles(dialogue) # 2. 按 ## 切分 raw_chunks = re.split(r'\n(?=## )', titled) chunks = [] for idx, chunk in enumerate(raw_chunks): title_match = re.match(r'^## (.+)$', chunk, re.M) title = title_match.group(1) if title_match else f"chunk_{idx}" content = chunk.strip() chunks.append({"title": title, "content": content, "path": f"/{title}"}) # 3. 重叠窗口 return self._add_overlap(chunks) def _inject_titles(self, dialogue: str) -> str: """ 简易规则:检测到商品/订单/售后关键词就插标题 """ lines = dialogue.split('\n') out_lines = [] for line in lines: if re.search(r'商品|订单|售后|政策', line) and not line.startswith("##"): out_lines.append(f"## {line.strip()}") out_lines.append(line) return '\n'.join(out_lines) def _add_overlap(self, chunks: List[Dict]) -> List[Dict]: for i in range(1, len(chunks)): prev_sentences = split_text_into_sentences(chunks[i-1]["content"]) overlap = ' '.join(prev_sentences[-self.overlap_sents:]) chunks[i]["content"] = overlap + '\n' + chunks[i]["content"] return chunks使用示例:
dialogue = """ User: 我昨天买的耳机左声道没声 Assistant: 抱歉给您带来不便,订单号是多少? User: 12345 Assistant: 已查到,请问能换红色吗? """ chunker = MarkdownChunker() chunks = chunker.split(dialogue) for c in chunks: print(c["title"], chunker._tokens(c["content"]))性能考量:token、延迟与准确率的“不可能三角”
实验环境:A100 40G,gpt-3.5-turbo,1000 条真实客服日志
| 分块大小 | 重叠句数 | 平均 token | 召回准确率 | 首响延迟 | |---|---|---|---|---|---| | 300 | 1 | 280 | 0.78 | 420 ms | | 600 | 2 | 560 | 0.85 | 480 ms | | 900 | 3 | 840 | 0.86 | 550 ms | | 1200 | 4 | 1100 | 0.86 | 680 ms |
结论:600 token + 2 句重叠是拐点,再往上准确率提升有限,延迟线性增加。线上最终采用 600/2 组合,P99 延迟 < 600 ms,满足业务 SLA。
避坑指南:生产环境踩过的四个坑
标题关键词误触发
用户说“商品页打不开”,算法插了## 商品:页打不开,后续召回把“页打不开”当商品名。
解决:用 NER 先抽实体,再决定是否插标题。重叠句重复计费
重叠部分在向量库出现两次,召回后未去重,导致 LLM token 翻倍。
解决:合并后按句子哈希去重,保持时序。路径过深过滤太狠
路径/售后/换新/配件/耳机四级,向量 metadata 过滤写死path=/售后/换新,导致“配件”相关块被漏掉。
解决:过滤条件用前缀匹配path.startswith('/售后')。并发写入冲突
多轮对话实时追加,Chunk 边聊边写,向量库出现旧块覆盖新块。
解决:对话级分布式锁 + 版本号,写前比对updated_at。
安全建议:别让日志成为隐私炸弹
- 标题注入前先做脱敏,订单号、手机号、地址统一掩码
- 向量库存向量不存原文,原文放加密对象存储,Key 与业务系统隔离
- 支持用户“一键遗忘”:删除向量 + 覆盖写入随机噪声向量,防止 Embedding 反解
- 内部灰度日志开启采样,敏感字段走公司统一脱敏 SDK,避免研发手动打日志
效果展示:线上 A/B 数据
上线两周,客服人效提升 18%,用户重复描述率下降 27%,Top3 差评关键词从“答非所问”变成“希望更快”。
还没完:两个开放问题留给读者
- 重叠窗口目前按句子数硬编码,如果换成“语义单元”动态计算,是否能在更少 token 下保持连贯?
- 标题层级依赖规则注入,有没有可能让模型在对话中自动学习“何时该另起一章”,实现完全无监督?
欢迎在评论区抛出你的脑洞或 PR,一起把客服助手做成“过目不忘”的金牌客服。