客服转过来一条投诉,语气挺客气但能看出有点无奈:「我前面明明说过我是 Windows,怎么聊到后面又让我装 Linux 的包了。」
我把那个会话的消息拉出来看。用户在第 2 轮提了一句系统环境,之后一直在问安装步骤,到第 18 轮模型给了一段 apt-get 命令。
不是模型记性差,是我们根本没把第 2 轮发过去。
代码里这段写得很清楚:
def _ai_history(session):
"""给 AI 的上下文:最近若干条非系统消息,转成 OpenAI 的 role 形式。"""
recent = list(session.messages.exclude(sender_type='system').order_by('-id')[:6])
recent.reverse()
return [{
'role': 'assistant' if m.sender_type in ('staff', 'ai') else 'user',
'content': m.content,
} for m in recent]
只取最近 6 条。第 18 轮的时候,窗口里是第 12 到 17 轮,全在讨论某个报错,没有人再提 Windows。模型看到一堆 Linux 相关的排查对话,顺着往下给命令,其实很合理。
generate_reply 那边还会再截一次:
for item in (history or [])[-6:]:
...
messages.append({'role': role, 'content': content[:500]})
messages.append({'role': 'user', 'content': user_content[:1000]})
两层 6 条,加上每条 500 字、当前问题 1000 字,都是当初为了控延迟和成本加的。写的时候我脑子里想的是"聊天嘛,最近几句够用了",没想过用户会把关键前提放在第一句、然后二十轮都不再重复。
越聊越贵这件事
另一个方向的麻烦是钱。
模型本身没有状态。第 N 轮的请求里,得把前 N-1 轮的问答、系统提示词、这一轮的问题,全部重新发一遍。所以每一轮的输入长度是随轮次线性涨的,而累计输入量差不多按平方涨。
我们截到 6 条之后,单轮长度被压平了,但"每轮都要重发"这件事没变。粗算一下:
| 轮次 | 本轮输入 token | 累计输入 token |
|---|---|---|
| 1 | ~400 | 400 |
| 5 | ~1000 | 3500 |
| 10 | ~1300 | 9500 |
| 20 | ~1400 | 23000 |
表里是估的,真数得看 usage。趋势是真的:前十轮花掉的 token,比后十轮少一大截,可用户问的问题难度根本没变。
这也是"聊久了变慢"和"聊久了变贵"其实是同一件事的原因。上下文越长,每次要传的、要算的都越多。
至于"上下文窗口"那个上限,我们从来没碰到过。截到 6 条之后离窗口上限还差得远,先撞上的永远是成本和延迟,不是容量。这跟我一开始的想象不太一样——我还以为会有个明确的"装不下了"的报错。
试过的几种补法
第一种是加大窗口,把 6 条改成 20 条。第 2 轮那句环境确实能带上了,但单轮 token 立刻翻两三倍,延迟肉眼可见地涨,而且历史里塞进大量无关排查过程之后,模型反而更容易被带偏。试了两天撤了。
第二种是摘要压缩:每隔几轮,把更早的对话交给模型压成一小段事实,放进上下文开头。这个思路挺对,做起来有两处别扭。一是压缩本身要再调一次模型,省下的输入 token 有一部分被这次调用吃回去了;二是压缩器会丢东西,我们那句"我是 Windows"到底能不能被保留,取决于它在摘要里被判为重要与否,等于把不确定性从一处挪到了另一处。
最后用的是第三种,最笨也最有效:把少数几个关键槽位显式抽出来,不指望历史。
messages = [{'role': 'system', 'content': SYSTEM_PROMPT}]
if session.client_env:
messages.append({'role': 'system', 'content': f'用户环境:{session.client_env}'})
用户第一次提到系统、客户端版本、会员状态这类信息时,单独存一个字段,之后每轮都拼在历史前面。它不参与截断,也不怕聊多久。代价是得维护"哪些算关键槽位"这份清单,而且得处理用户后来改口的情况——我们目前是覆盖,没做版本,改口之后旧值就没了,客服那边偶尔会问"你之前不是说是 Mac 吗"。
什么时候该开新会话
站内客服有个设计我觉得挺巧:客服点"结束会话"只是把 status 置成 closed,用户再发言时 chat_send 会自动 reopen,复用原会话而不是新建一个。这样客服能看到完整上下文。
但从 AI 的角度看,这个设计会让上下文无限长。一次会话断断续续聊三天,历史几百条,全靠那 6 条窗口撑着,槽位字段也就那么几个。
我们讨论过"超过 N 轮就提示开新话题",一直没做。原因很实际:用户不会配合。他觉得自己在同一件事里,你让他重讲一遍背景,体验比答错还差。
跨会话记忆那套(把用户偏好长期存起来,下次来还认得)听着是正解,一想就知道要碰隐私、要碰账号体系、要碰"记错了算谁的"。这事先搁着。
槽位抽取现在做得很土:几个正则加一张关键词表,看到 Windows、win11、Mac 这类词就往 client_env 里塞。误判不少,有人说"我手机上装不了",被记成了移动端环境;还有人转述同事的问题,"他那台 Linux"被当成了本人。我们没打算把它做准,只求别把最要紧的那句淹掉。
用户改口也是个麻烦。有人开头说 Windows,聊到第十轮说"我换 Mac 了",代码是直接覆盖,旧值不留。客服后来翻记录问了句"你之前不是说是 Windows 吗",用户回"我早换了呀",场面有点尴尬。做成带时间的槽位列表不难,难的是让模型知道该信哪一条,一直没动手。
会话自动归档也讨论过:超过 N 轮或者静默 N 天就归档,新发言时开新会话。方案写了一半停了,因为客服那边明确说,用户最烦的就是"重新讲一遍背景"。技术上省下来的那点 token,换来的可能是一次投诉。
VidDown 技术博客,站上客服那个兜底也是同一套取舍:宁可少说。
那个 Windows 用户后来自己又发了一句"算了我已经解决了",会话就那么关着,状态还是 open。我偶尔在后台列表里看见它,想起该做个自动归档,然后又去忙别的了。