背景与痛点:为什么“说人话”这么难?
过去一年,我陆续给三款 SaaS 产品接入了大模型能力:客服机器人、数据洞察助手、内部知识问答。上线前都觉得自己 prompt 写得挺“性感”,结果一上真实流量就翻车:
- 用户多问两句,模型就开始“装失忆”,答非所问;
- 把整段对话历史都塞进去,一次请求就占掉 6k tokens,账单飙红;
- 换成长上下文窗口模型,延迟又直接翻倍,用户侧疯狂转圈。
归根结底,问题集中在两条线:
- Context 管理:到底该给模型看多少历史?怎样在“记得住”和“传得快”之间做权衡?
- Prompt 设计:如何让模型在有限长度内稳定输出期望格式,又不被几句口语带偏?
下面把我趟出来的实战套路拆给大家。
技术对比:窗口、向量、摘要,谁才是最佳配角?
先放一张总览图,方便后面引用:
1. 滑动窗口(Windowing)
- 原理:只保留最近 N 轮对话,超出的直接丢弃。
- 优点:实现简单,token 可控。
- 缺点:一旦关键信息在 N 轮之前,模型就永久失忆。
2. 向量召回 + 动态拼接(RAG 式)
- 原理:把历史对话或文档切片向量化,按用户问题语义检索 Top-K 最相关片段,再拼进 prompt。
- 优点:理论上“无限记忆”,且 token 随 K 值线性增长,可预测。
- 缺点:依赖向量库质量,检索错误会直接带偏模型;每次召回都要一次向量搜索,增加 P99 延迟。
3. 摘要链(Summary Chain)
- 原理:每当对话轮次达到阈值,先让模型自己总结前文,存成摘要,后续只传摘要+最近 N 轮。
- 优点:兼顾长记忆与长度控制。
- 缺点:多一次 LLM 调用,摘要可能丢失细节;摘要本身也要占 token。
4. 混合策略(Hybrid)
线上最常用:最近 3 轮完整对话 + 动态向量召回 2 条历史 + 上一步摘要。
经验值:在 4k 上下文模型里,可把平均输入压到 2k tokens 以内,召回率保持 90%+。
核心实现:给你一套能直接抄的 Python 骨架
下面代码演示“混合策略”在对话系统中的落地,依赖 openai>=1.0 与 chromadb(向量库)。
为了阅读方便,我拆成三步讲:
1. 对话状态机封装
# dialog_session.py import time from typing import List, Dict import openai from chromadb import Client as ChromaClient from chromadb.config import Settings class DialogSession: """ 负责三件事: 1. 维护原始对话历史 2. 生成/更新摘要 3. 向量库存储 & 召回 """ SUMMARY_TRIGGER = 6 # 轮数阈值 WINDOW_SIZE = 3 # 最近保留 RETRIEVAL_TOPK = 2 # 向量召回条数 def __init__(self, openai_key: str, collection_name: str = "chat_history"): openai.api_key = openai_key self.chroma = ChromaClient(Settings(anonymized_telemetry=False)) self.collection = self.chroma.get_or_create_collection(collection_name) self.raw_history: List[Dict[str, str]] = [] # 完整历史 self.summary: str = "" # 摘要 def add_turn(self, role: str, content: str): """记录一轮对话""" self.raw_history.append({"role": role, "content": content}) # 向量化入库,方便后续语义检索 self.collection.add( documents=[content], metadatas=[{"role": role, "ts": time.time()}], ids=[f"{time.time()}"] ) def _generate_summary(self) -> str: """调用 LLM 生成摘要""" prompt = ( "请将以下对话浓缩成 2 句话以内,保留关键事实与上下文:\n" + "\n".join(f"{m['role']}: {m['content']}" for m in self.raw_history) ) rsp = openai.chat.completions.create( model="gpt-3.5-turbo", messages=[{"role": "user", "content": prompt}], max_tokens=120 ) return rsp.choices[0].message.content.strip() def _retrieve_related(self, query: str) -> List[str]: """向量召回 Top-K 相关历史""" res = self.collection.query(query_texts=[query], n_results=self.RETRIEVAL_TOPK) return [d for d in res["documents"][0]] def build_prompt(self, user_input: str) -> List[Dict[str, str]]: """组装最终 prompt""" # 1. 触发摘要 if len(self.raw_history) > self.SUMMARY_TRIGGER and not self.summary: self.summary = self._generate_summary() # 2. 向量召回 related = self._retrieve_related(user_input) # 3. 截取最近窗口 recent = self.raw_history[-self.WINDOW_SIZE:] # 4. 拼装 system + context system = ["你是智能客服助手,请根据背景信息回答。"] if self.summary: system.append(f"历史摘要:{self.summary}") if related: system.append("相关背景:\n" + "\n".join(related)) messages = [{"role": "system", "content": "\n".join(system)}] messages.extend(recent) messages.append({"role": "user", "content": user_input}) return messages2. Prompt Engineering 模板
再给一个“万能填空”版,适合输出结构化 JSON,也方便后续做字段校验。
# prompt_templates.py def json_bot_template(goal: str, required_keys: List[str], context: str = "") -> str: """ 返回一段 system prompt,要求模型必须输出合法 JSON, 并包含指定字段,否则就重试。 """ key_list = ", ".join(required_keys) return f""" {context} 你的目标是:{goal} 回答时请严格遵循以下格式,不要输出任何多余文字: {{ "{required_keys[0]}": <内容>, "{required_keys[1]}": <内容>, ... }} 确保 JSON 可被 Python json.loads 解析。 """调用示例:
messages = [ {"role": "system", "content": json_bot_template( goal="判断用户意图并抽取参数", required_keys=["intent", "product", "date"], context="你是电商售后助手。")}, {"role": "user", "content": "我想换货,订单里那双鞋尺码小了"} ]3. 端到端调用
# main.py from dialog_session import DialogSession import openai, os, json session = DialogSession(openai_key=os.getenv("OPENAI_KEY")) def chat(user_input: str) -> str: messages = session.build_prompt(user_input) rsp = openai.chat.completions.create( model="gpt-3.5-turbo", messages=mes, temperature=0.3, max_tokens=400 ) assistant_msg = rsp.choices[0].message.content session.add_turn("user", user_input) session.add_turn("assistant", assistant_msg) return assistant_msg if __name__ == "__main__": while True: user = input("User: ") print("Bot:", chat(user))跑通后,你可以用tokencount工具观察:一轮真实对话平均 1.2k~1.5k tokens,比直接丢全史节省 60%+,且摘要 + 召回能覆盖 90% 的“跨轮引用”场景。
性能考量:省 token 也要省时间
token 预算公式
总输入 = system + summary + retrieved + window + user
上线前用 1000 条真实日志跑一遍分位统计,确保 P95 不超过模型上限的 75%,给网络抖动留余量。向量库延迟
Chroma 本地 Docker 版在 5 万条 768 维向量下,Top-K 查询约 30~50ms;若对延迟敏感,可提前做“批量预召回”放进 Redis,用户请求时直接 O(1) 读取。摘要频率
摘要也是 LLM 调用,建议异步线程做,或在“用户正在输入”间隙触发,避免卡住首 token 时间。并行生成
对结构化输出,可让模型一次返回带字段的 JSON,减少多轮追问;若字段多,可拆成两次并行调用再合并,降低单请求 max_tokens。
避坑指南:生产环境 5 个深坑
token 估算不准
中文 & 英文混排时,官方tiktoken会多算 5~10%,预算要再留 10% 安全垫。摘要雪崩
摘要本身也会累加 token,记得给摘要长度设硬上限(如 120 tokens),超长就再摘要一次,防止无限套娃。向量 ID 冲突
时间戳当 ID 在并发高时会碰撞,改用uuid.uuid4()。JSON 输出多余解释
即使 system 里强调“不要废话”,小概率仍会夹带 “Here is the json:” 前缀,解析前务必 strip() 再做json.loads,失败就重试。温度双标
摘要和召回对事实准确性要求高,temperature 设 0.1~0.3;创意型回答可单独调高,但别全局复用同一参数。
开放式思考
- 当上下文突破 100 万字时,摘要+向量还能线性扩展吗?是否需要“分层记忆”或“图索引”新架构?
- 多模态(图文、音频)场景下,Context Engineering 的 token 概念被重新定义,你会如何量化成本?
- Prompt 自动优化(如 DSPy、APE)正在兴起,人工模板还有多大生存空间?还是说“人机共写”才是终局?
把上面的代码和模板搬到你的项目里,跑通日志看看 token 曲线,再回来聊聊你的答案。祝你少踩坑,多拿 A/B 正向指标!