Agent实践7 - 个人AI助手 - 阶段2 多轮对话记忆 & Token统计

5 阅读9分钟

个人AI助手 - 阶段2 多轮对话记忆 & Token统计

1. 阶段定位

本节是个人 AI 助手第 2 阶段:多轮对话记忆与实时 Token 统计。

核心任务是接入 LangChain 记忆组件、打通 Token 统计、补齐前端会话管理。

本阶段不做 RAG、不做工具调用、不做持久化存储,只完成基础对话优化,为后续长对话优化提供支撑。

2. 阶段目标

  1. 功能目标:完成多轮连贯对话、实时 Token 统计、对话清空/管理,打通「用户输入 — 记忆拼接 — 模型调用 — Token 统计 — 前端展示」链路。
  2. 架构目标:落地「消息历史容器 + 会话管理器」记忆体系,统一 Token 统计流程,解耦记忆组件与核心业务链。

3. 架构与选型

3.1 架构分层

  • 前端层:负责界面渲染、session_id 生成、Token 面板展示、清空对话;不负责历史拼接与 Token 计算。
  • 后端层:负责会话历史存储、记忆绑定、Token 统计、结构化返回;统一 session_id 隔离。
  • 模型层:负责对话生成,不感知记忆与统计逻辑。

3.2 技术选型

记忆组件

  • 选型:InMemoryChatMessageHistory + RunnableWithMessageHistory
  • 淘汰:手写列表、ConversationBufferMemory + ConversationChain
  • 原因:手写仅适合 Demo,扩展性差;旧版组件已废弃,不支持多用户隔离与 LCEL。
  • 优势:格式标准、支持多用户隔离、可无缝对接 Token 统计与持久化。

Token 统计

  • 选型:dashscope 内置 Qwen 官方 tokenizer
  • 淘汰:接口返回 Token、通用 tiktoken
  • 原因:接口返回属于事后结果,流式场景不返回;通用 tokenizer 与 Qwen 计费口径不一致。
  • 优势:与 API 计费口径一致,支持请求前预判与流式事后统计。

4. 核心设计

  1. 输入标准化:按 ChatML 格式拼接系统提示 + 历史 + 当前问题,插入 <|im_start|> / <|im_end|>,末尾追加 <|im_start|>assistant。
  2. 调用标准化:用 RunnableWithMessageHistory 封装记忆读写,固化 input_messages_key / history_messages_key / get_session_history。
  3. 输出标准化:统一返回「回答内容 + 输入 Token + 输出 Token + 历史总 Token」。

5. 核心实现

5.1 核心代码

5.1.1 会话历史仓库 + 提示词模板
# config/llm_config.py
import os
from typing import List
from dotenv import load_dotenv
from langchain_community.chat_models import ChatZhipuAI
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableWithMessageHistory

load_dotenv()
ZHIPU_API_KEY = os.getenv('ZHIPU_API_KEY')

# 模型参数
MODEL_NAME = 'glm-4-flash'
TIMEOUT = 60

# 全局会话仓库:session_id → 独立聊天记录
store = {}

chat_prompt_template = ChatPromptTemplate.from_messages([
    ("system",
     "你是瑶瑶的私人助手,协助她完成工作生活学习上的事情。\n"  # Python 隐式字符串拼接:多个字符串字面量相邻出现,解释器会自动把它们合并成一个字符串
     "如果用户消息中提供了参考资料,请务必严格基于资料回答;"
     "资料中没有的信息不要编造,可以说明“资料中未提及”。\n"
     "如果没有提供参考资料,则正常回答。"),
    MessagesPlaceholder("history"),
    ("user", "参考资料:\n{context}\n\n用户问题:{user_input}"),
])

def get_session_history(session_id: str):
    if session_id not in store:
        # InMemoryChatMessageHistory 是 LangChain 提供的内存存储,程序运行期间有效,重启即丢失
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]
5.1.2 带记忆的链
# config/llm_config.py
import os
from typing import List
from dotenv import load_dotenv
from langchain_community.chat_models import ChatZhipuAI
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableWithMessageHistory

load_dotenv()
ZHIPU_API_KEY = os.getenv('ZHIPU_API_KEY')

# 模型参数
MODEL_NAME = 'glm-4-flash'
TIMEOUT = 60

# 全局会话仓库:session_id → 独立聊天记录
store = {}

chat_prompt_template = ChatPromptTemplate.from_messages([
    ("system",
     "你是瑶瑶的私人助手,协助她完成工作生活学习上的事情。\n"  # Python 隐式字符串拼接:多个字符串字面量相邻出现,解释器会自动把它们合并成一个字符串
     "如果用户消息中提供了参考资料,请务必严格基于资料回答;"
     "资料中没有的信息不要编造,可以说明“资料中未提及”。\n"
     "如果没有提供参考资料,则正常回答。"),
    MessagesPlaceholder("history"),
    ("user", "参考资料:\n{context}\n\n用户问题:{user_input}"),
])

def get_session_history(session_id: str):
    if session_id not in store:
        # InMemoryChatMessageHistory 是 LangChain 提供的内存存储,程序运行期间有效,重启即丢失
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]

class LLM:
    # 瑶瑶的私人助手 Chain
    # 自带:提示词 + 输出解析器 + 流式输出 + 智谱模型 + 多轮对话记忆
    @staticmethod
    def get_chain(
        model_name: str = MODEL_NAME,
        timeout: int = TIMEOUT,
    ):
        # 预设参数组合:不同业务场景(RAG问答 / 长文深度 / 快速省钱 / 工具调用)
        # plus = 日常 RAG:0.7 + 2000 + streaming = True
        # max = 长文 / 深度:0.6 + 4096 + streaming = True
        # turbo = 省钱快答:0.7 + 1024 + streaming = True
        # 工具调用:温度 0.2、不流式
        chat_model =  ChatZhipuAI(
            model=model_name,
            api_key=ZHIPU_API_KEY,
            timeout=timeout,
            temperature=0.7,
            max_tokens=2000,
            streaming=True,
            verbose=False
        )

        parser = StrOutputParser()
        chain = chat_prompt_template | chat_model | parser

        # 把记忆绑定到链(LCEL 唯一正确方式),得到带记忆的链
        # 接收一个普通的 LCEL 链,给它"套上"记忆能力,返回一个新的、带记忆的链
        chain_with_memory = RunnableWithMessageHistory(
            chain, # 被包装的原始链
            get_session_history=get_session_history,  # 一个回调函数,用来根据 session_id 拿到对应的历史记录对象;按会话隔离记忆
            input_messages_key="user_input",  # 对应 {user_input}
            history_messages_key="history",  # 对应 history
        )

        return chain_with_memory
5.1.3 Token 统计工具
# utils/token_util.py
from dashscope import get_tokenizer
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from config.llm_config import get_session_history, chat_prompt_template

# ------------------------------
# 全局变量:存储每个会话的历史总Token
# ------------------------------
session_token_total = {}
tokenizer = get_tokenizer("qwen-plus")

def build_qwen_chatml(msg_list):
    chatml = ""
    for m in msg_list:
        if m.type == "system":
            r = "system"
        elif m.type == "human":
            r = "user"
        elif m.type == "ai":
            r = "assistant"
        else:
            r = "user"
        chatml += f"<|im_start|>{r}\n{m.content}<|im_end|>\n"
    chatml += "<|im_start|>assistant\n"
    return chatml

# ===================== 工具:拼接完整prompt并计算输入token数量 =====================
def calculate_input_tokens(user_input,session_id, context):
    # 获取该会话的历史聊天记录
    history = get_session_history(session_id)
    # 拼装系统提示词+历史消息+当前用户提问
    prompt_template = ChatPromptTemplate.from_messages([
        ("system", "你是瑶瑶的私人助手,协助她完成工作生活学习上的事情"),
        MessagesPlaceholder("history"),
        ("user", "{user_input}"),
    ])
    prompt_val = chat_prompt_template.invoke({
        "user_input": user_input,
        "context": context,
        "history": history.messages
    })
    # 转为通义chatml格式后统计token
    chatml_text = build_qwen_chatml(prompt_val.to_messages())
    return len(tokenizer.encode(chatml_text))
5.1.4 对话接口与后置统计
# api/chat_endpoint.py
import json
import time
from datetime import datetime
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from langchain_classic.retrievers import ContextualCompressionRetriever
from pydantic import BaseModel
# 项目内部模块导入:配置、LLM、向量、缓存工具、Token工具
from config.app_config import CACHE_TTL
from config.llm_config import get_session_history
from core.embeddings import embeddings
from core.llm_loader import get_chain
from utils.cache_util import session_question_answer_total, init_session_cache, clean_expired_session_cache, get_cached_answer
from utils.token_util import tokenizer, session_token_total, calculate_input_tokens
from utils.retriever import get_best_retriever
from core.reranker import RawDashScopeRerank
from core.context_compress import create_compression_retriever

router = APIRouter()

# 请求入参模型:前端请求必须携带用户提问+会话ID
class ChatRequest(BaseModel):
    user_input: str
    session_id: str
    use_rag: bool
    
# ===================== 获取会话历史消息接口 =====================
@router.get("/get_history")
def get_history(session_id: str):
    history = get_session_history(session_id)
    return {"history": history.messages}

# ===================== 对话主接口 =====================
# 同步接口,返回流式响应,实际生成逻辑下放至 generate_stream
@router.post("/chat")
def chat(body: ChatRequest):
    # body是请求体(FastAPI 按类型自动从 body 解析)
    # 若要访问完整请求(headers/url/method 等),需额外加一个 Request 类型入参
    print("body: ", body)
    chain = get_chain()
    # 把 session_id 放进 config.configurable
    config = {"configurable": {"session_id": body.session_id}}
    # 返回流式生成器
    # generate_stream 负责"怎么产出数据"
    # StreamingResponse 负责"怎么把产出的数据通过 HTTP 一块块送出去"
    return StreamingResponse(generate_stream(body, chain, config), media_type="text/plain")

# ===================== 流式生成总入口函数:串联全流程 =====================
# 执行顺序:初始化缓存→清理过期→匹配缓存→命中缓存直接返回/未命中调用LLM
def generate_stream(req: ChatRequest, chain, config):
    # 记录接口开始时间
    start_time = datetime.now()
    print("开始时间:", start_time.strftime("%Y-%m-%d %H:%M:%S"))
    # 问题文本向量化
    question_vector = embeddings.embed_query(req.user_input)
    yield from stream_llm_result(req, chain, config, start_time, question_vector)

# ===================== 工具:调用LLM流式输出,finally中收尾统计&落地缓存 =====================
def stream_llm_result(req, chain, config, start_time, question_vector):
    chunk_list = ""
    try:
        # 迭代流式分片,拼接完整回答
        for chunk in chain.stream({"user_input": req.user_input, "context":context}, config=config):
            chunk_list += chunk
            yield chunk
    finally:
        # LLM生成结束后统一处理
        yield from handle_llm_finish(req, chunk_list, start_time, question_vector, context)

# ===================== 工具:LLM生成完毕后置处理:Token统计+缓存落地+返回尾部JSON =====================
def handle_llm_finish(req, chunk_list, start_time, question_vector, context):
    end = datetime.now()
    cost_ms = int((end - start_time).total_seconds() * 1000)
    print(f"本次输出耗时:{cost_ms} ms")

    # 计算输入prompt总token
    input_tokens = calculate_input_tokens(req.user_input, req.session_id, context)
    # 计算模型输出回答token
    output_tokens = len(tokenizer.encode(chunk_list))

    session_id = req.session_id
    # 累加当前会话历史总token
    last_total = session_token_total.get(session_id, 0)
    new_total = last_total + input_tokens + output_tokens
    session_token_total[session_id] = new_total

    print("本次输入 token:", input_tokens)
    print("本次输出 token:", output_tokens)
    print("历史总Token:", new_total)

    # 将本轮问答向量+答案写入内存缓存,设置过期时间
    session_question_answer_total[session_id].append({
        "question_vector": question_vector,
        "answer": chunk_list,
        "expire_time": time.time() + CACHE_TTL
    })

    # 尾部返回token统计json
    yield json.dumps({
        "input_tokens": input_tokens,
        "output_tokens": output_tokens,
        "history_total": new_total,
        "total_time": cost_ms
    }, ensure_ascii=False)

5.2 问题修复

  • Token 与接口不一致:未按 ChatML 拼接。解决:严格插入特殊标记,末尾追加 <|im_start|>assistant。
  • 流式输出 Token 丢失:统计未放 finally。解决:输出 Token 统计放入 finally。
  • 清空后旧历史仍被读取:未按 session_id 隔离。解决:前端重置 session_id,后端依赖变更自动隔离。

6. 交付成果

  1. 架构交付:记忆架构、Token 统计链路、session_id 隔离分层。
  2. 后端工程交付:/chat 接口、/history 接口、会话仓库、Token 计数工具、历史总 Token 累加器。
  3. 工程规范交付:session_id 隔离规范、ChatML 拼接规范、Token 统计执行时机规范。
  4. 功能交付:多轮连贯对话、实时 Token 统计、对话清空/管理、刷新后历史恢复。

7. 运行效果

  1. 服务运行:uvicorn 正常启动,/docs 可访问,无报错。
  2. 接口运行:/chat 返回回答 + 输入/输出/历史总 Token,参数正确。
  3. 对话交互:同一 session_id 下第二轮能正确回答「我是谁」,流式逐字返回、无截断。
  4. 整体效果:多轮记忆、Token 统计、清空对话、历史恢复四条链路均正常。

8. 后续迭代价值

本阶段搭建的记忆架构与 Token 统计底座,后续可直接迭代窗口记忆、历史摘要、会话持久化、Token 计费风控等功能,无需重构底层代码。