前五期下来,机器人已经像模像样了:50行检索引擎,第2期接上大模型做问答,第3期加了健康监控,第4期装上重试降级熔断,第5期搞定SSE流式输出。看着挺完整,真用起来你会撞上一个尴尬:它没有记性。

你问"我家猫三岁了,该打什么疫苗",它答得头头是道。紧接着问"那它平时吃啥好",它一脸懵:猫?什么猫?每一轮对话都是全新的开始,上一句说过的话,下一句全忘光。

病根不在模型,在我们。每次调API,我们只把当前这一句塞进prompt,历史对话根本没传过去。模型看不到历史,当然记不住。

修法也直接:维护一个对话历史列表,每次请求把历史拼进prompt一起发过去。听起来简单,真写起来有三个坎:token预算会爆,system提示词不能丢,历史太长得压缩。

今天把这三个坎一次踩平。代码40行,零依赖,复制就能跑。

先说环境。纯Python 3.9以上,不装任何包。大模型调用部分我用一个假函数代替,你换成自己的requests调用就行,记忆逻辑完全一样。

核心是一个ChatMemory类,干四件事:记消息、估token、超预算裁剪、拼prompt。

class ChatMemory:
    """多轮对话记忆:按token预算做滑动窗口裁剪"""

    def __init__(self, max_tokens=2000, reserve_ratio=0.5):
        self.messages = []          # 历史消息
        self.max_tokens = max_tokens
        self.reserve_ratio = reserve_ratio  # 给新回答预留的token比例

    def estimate_tokens(self, text):
        # 粗估:中文约1.5字1个token,英文约4字符1个token
        cn = sum(1 for ch in text if '\u4e00' <= ch <= '\u9fff')
        return int(cn / 1.5 + (len(text) - cn) / 4)

    def total_tokens(self):
        return sum(self.estimate_tokens(m["content"]) for m in self.messages)

    def add(self, role, content):
        self.messages.append({"role": role, "content": content})
        self._trim()

    def _trim(self):
        budget = int(self.max_tokens * (1 - self.reserve_ratio))
        while self.messages and self.total_tokens() > budget:
            # system提示词永远保留,删最旧的一条
            if self.messages[0].get("role") == "system" and len(self.messages) > 1:
                del self.messages[1]
            else:
                del self.messages[0]

    def build_prompt(self):
        return "\n".join(f"{m['role']}: {m['content']}" for m in self.messages)

token估算用的是粗算法。正式上线建议换tiktoken精确算,演示和自用这个够了,误差方向是往少了估还是往多了估,自己心里要有数。

裁剪逻辑注意两点。第一,system提示词永远不动,它是机器人的"人设说明书",裁掉就跑偏。第二,从最旧的开始删,保留最近的对话。人也一样,刚说的事记得最牢。

验证分两步。第一步测记性:

mem = ChatMemory(max_tokens=2000)
mem.add("system", "你是问答机器人助手。")
mem.add("user", "我叫小李。")
mem.add("assistant", "你好小李,有什么可以帮你?")
mem.add("user", "我叫什么名字?")
print(mem.build_prompt())

prompt里带着"我叫小李",模型再答"我叫什么名字"就不会懵。第二步测裁剪:往里塞60轮闲聊,token总数被卡在预算线以下,system提示词还在第一位。我实测塞了60轮,裁完剩11条,token 96,system原地不动。

光裁剪还不够。有些场景历史很长,全删了可惜,比如用户聊了半小时需求。这时候上摘要兜底:把要删掉的旧对话压缩成一句话,塞回历史开头。

    def summarize_old(self, keep_last=4):
        """把keep_last轮之前的旧对话压缩成一句话摘要"""
        if len(self.messages) <= keep_last + 1:
            return
        head = self.messages[:1] if self.messages[0].get("role") == "system" else []
        old = self.messages[len(head):-keep_last]
        import re
        keys = []
        for m in old:
            keys += re.findall(r'\d+(?:\.\d+)?万?', m["content"])
        summary = f"之前聊过{len(old)//2}轮对话"
        if keys:
            summary += f",提到数字:{'、'.join(keys[:5])}"
        self.messages = (head
            + [{"role": "system", "content": "对话摘要:" + summary}]
            + self.messages[-keep_last:])

演示版用关键词拼接凑摘要。生产环境调一次模型做真正的摘要,效果天差地别,成本多一次调用,值。

踩坑时间,四个都是我真踩过的。

第一个坑:token估算偏差。中文按2字1token估,实际混着英文和标点,估少了15%。预算设2000,实际发出2300,API直接报超限。解决办法:预算打八折用,或者上tiktoken精确算。

第二个坑:多线程下历史串了。Web服务一个进程服务几十个用户,ChatMemory实例千万别做成全局单例。每个会话一个实例,用session_id做key存字典里,不然A用户的问题会出现在B用户的prompt里。

第三个坑:摘要把关键信息吃了。"用户说预算5万"这种数字,摘要模型觉得不重要给扔了,后面报价全错。我的做法:摘要前先正则捞出数字、日期、人名,拼在摘要前面,硬保。

第四个坑:system提示词被裁。早期版本裁剪没做保护,一轮长对话下来,人设说明书没了,机器人开始胡言乱语。记住:system永远豁免,这条写进_trim第一行。

收藏这篇,下次给机器人加记忆,照着这40行代码改,半小时搞定。

下期预告:Python实战第7期讲评测。你的问答机器人答得准不准,不能靠感觉,我教你搭一个自动打分流水线,关注我不错过。

讨论时间:你做的对话应用里,最长的一次对话有多少轮?token账单炸过吗?评论区聊聊。

—— Cyrus

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐