基于 LangChain 与通义千问构建带记忆的多轮对话中枢
本章带你完成 阶段 1:「灵语」智能对话中枢 项目: 基于 LangChain + 通义千问 构建一个具备多轮对话记忆、意图识别、流式输出、实时监控的智能对话中枢, 可直接用于电商客服、学习辅导、代码咨询等场景。
这个项目是一个标准的 Python + LangChain + Streamlit 应用, 建议为它单独创建虚拟环境,并安装下面这些依赖。
streamlit>=1.32.0
# LangChain 依赖(版本锁定,避免"在我电脑上能跑")
langchain-classic==1.0.0
langchain-core>=1,<2
langchain-community>=0.3,<0.4
langchain-text-splitters>=1,<2
langsmith>=0.1.17,<1
# 通义千问(DashScope)SDK
dashscope
# 可选:从 .env 加载环境变量
python-dotenv>=1.0.0
# 可选:Redis / MySQL(进阶会话存储与结构化存储)
redis
pymysqllangchain-experimental。
但如果你在后续章节/扩展里使用 DataFrame Agent(例如 create_pandas_dataframe_agent),需要额外安装:
pip install langchain-experimental pandasstreamlit run main.py 提示 command not found,通常是以下原因:
pip install -r requirements.txt 或单独安装:pip install streamlit
source venv/bin/activate(macOS/Linux)venv\Scripts\activate(Windows)
echo $PATH | grep -i pythonpython -m streamlit run main.py
python -m pip list | grep streamlitpython -m pip install streamlit
在项目目录创建 requirements.txt 后,可以使用如下命令安装:
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
聊天助手的"大脑"在 utils.py 中完成:
我们会用 ChatTongyi + ChatPromptTemplate + 手动窗口裁剪 + ChatMessageHistory
组合出一个支持多轮对话记忆、可控 Token 成本、可扩展的聊天链。
lingyu-chat/ ├── main.py # UI 入口(只做界面适配,不含业务逻辑) ├── utils.py # Facade:对外暴露 get_chat_response(),委托给 ChatService ├── chat_service.py # ChatService(核心编排:识别 → 记忆 → LLM → 写回) ├── config.py # 模型配置(API Key / 模型名 / temperature) ├── core/ │ ├── __init__.py │ ├── protocol.py # ChatRequest / ChatResponse(统一协议) │ ├── id_gen.py # ID 生成工具(前端负责生成所有 ID) │ ├── intent.py # IntentRecognizer(意图识别 + 多意图支持) │ └── memory.py # 会话记忆 + 窗口裁剪(原 ChatMessageHistory 逻辑) ├── requirements.txt # 依赖锁定(版本号固定) ├── .env.example # 环境变量模板(不含真实 Key) ├── Dockerfile # 容器化部署(可选) └── README.md # 启动说明 + 架构图 + 演示脚本
prompt | llm 这种写法让链路可组合、可插拔。MessagesPlaceholder + ChatMessageHistory + 手动窗口裁剪 实现可控 Token 成本的多轮记忆。
- 工程化加成:意图路由(控正确性)+ trace 日志(可复盘)。
✅ 从第2章“记忆管理”迁移过来的 3 个关键点
- 历史注入:用
MessagesPlaceholder("history") 把消息列表塞进 Prompt(不要自己拼字符串)。
- 会话隔离:用
session_id 作为 key,做到不同用户/不同浏览器不串话。
- 成本控制:默认 History 会越聊越长,需要“窗口裁剪/摘要/关键事实”策略(本章给一个最小窗口裁剪版)。
📄 多模块拆分版(可直接复制运行)
按文件名顺序复制,放到同一目录下,streamlit run main.py 即可启动。
📄 config.py — 模型配置(API Key / 模型名 / temperature)
python"""
config.py
配置文件 - 存储通义千问 API 密钥与模型配置
"""
import os
# 1) API Key:从环境变量中读取,避免写死在代码里
# macOS/Linux 示例:export DASHSCOPE_API_KEY=xxx
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
# 2) 模型名称:只改这一行就能切换(第1章:统一接口,轻松换模型)
TONGYI_MODEL = "qwen-turbo" # 可选: qwen-turbo, qwen-plus, qwen-max
# 3) 推荐:把“可调参数”也统一放配置里(第1章:参数决定稳定性/成本/延迟)
# temperature 越低越稳(客服/工具类建议 0.1~0.4)
TONGYI_TEMPERATURE = 0.3
# max_tokens 控制最长输出,避免一条消息生成过长导致成本与延迟失控
TONGYI_MAX_TOKENS = 1024
# 新版推荐:直接使用 ChatTongyi(无需 OpenAI 兼容 base_url)
📄 main.py — UI 入口,只做界面适配
pythonimport uuid
import streamlit as st
from utils import get_chat_response
st.title("💬 「灵语」智能对话中枢")
with st.sidebar:
dashscope_api_key = st.text_input("请输入通义千问 API 密钥:", type="password")
st.markdown("[获取通义千问 API 密钥](https://dashscope.console.aliyun.com/)")
st.markdown("---")
st.markdown("### 使用说明")
st.markdown("1. 在这里输入 API 密钥")
st.markdown("2. 在下方输入框中与 AI 对话")
st.markdown("3. AI 会自动记住对话历史")
# 为每个浏览器会话分配唯一 ID,用于区分对话历史(第2章:会话隔离)
if "session_id" not in st.session_state:
st.session_state["session_id"] = str(uuid.uuid4())
# 初始化消息历史(仅用于 UI 展示)
# 注意:这里的 messages 和 utils.py 里的 ChatMessageHistory 不是同一个东西:
# - UI messages:负责把对话展示在页面上
# - LangChain history:负责把历史注入 prompt 给模型用
if "messages" not in st.session_state:
st.session_state["messages"] = [
{"role": "ai", "content": "你好,我是通义千问聊天助手,有什么可以帮你的吗?"}
]
# 展示历史消息
for message in st.session_state["messages"]:
st.chat_message(message["role"]).write(message["content"])
prompt = st.chat_input("请输入你的问题...")
if prompt:
if not dashscope_api_key:
st.info("请输入你的通义千问 API 密钥")
st.stop()
# 把用户消息加入历史并展示
st.session_state["messages"].append({"role": "human", "content": prompt})
st.chat_message("human").write(prompt)
# 调用 LangChain 对话链(utils.py 会自动注入历史并更新历史)
with st.spinner("AI 正在思考中,请稍等..."):
response = get_chat_response(
user_input=prompt,
api_key=dashscope_api_key,
session_id=st.session_state["session_id"],
message_id=str(uuid.uuid4()),
trace_id=str(uuid.uuid4().hex)
)
# 保存并展示 AI 回复
msg = {"role": "ai", "content": response}
st.session_state["messages"].append(msg)
st.chat_message("ai").write(response)
📄 utils.py — Facade,对外只暴露一个函数,委托给 ChatService
python"""
utils.py — Facade 层
为什么保留这个文件:
- UI 层习惯 from utils import ...,改入口成本高
- utils.py 本身只做"接收参数 → 组装 Request → 调用 ChatService → 返回字符串"
- 真正的业务逻辑都在 core/ 目录下,utils.py 不堆任何实现细节
"""
from __future__ import annotations
from chat_service import ChatService # 核心服务,移到根目录避免循环导入
from core.protocol import ChatRequest
# 单例,避免重复初始化(整个进程共享一个 ChatService 实例)
_service = ChatService()
def get_chat_response(*, user_input: str, api_key: str, session_id: str, message_id: str, trace_id: str) -> str:
"""唯一对外函数:UI / API 层统一调用这里。
注意:message_id 和 trace_id 都必须由前端生成。
- message_id:用于幂等去重,前端负责重试
- trace_id:用于链路追踪,方便日志排查
执行步骤(面试时可以说清楚):
1️⃣ 组装请求:把前端参数封装成 ChatRequest
2️⃣ 委托给 ChatService.handle() 处理(核心业务逻辑)
3️⃣ 返回纯文本:UI 层只需要字符串,不需要复杂对象
"""
# 第一步:组装请求(参数校验与封装)
req = ChatRequest(
session_id=session_id,
message_id=message_id, # 前端生成的消息唯一 ID(用于幂等去重)
trace_id=trace_id, # 前端生成的链路追踪 ID(方便日志排查)
user_input=user_input,
api_key=api_key,
)
# 第二步:委托给核心服务处理(所有业务逻辑都在 ChatService)
resp = _service.handle(req)
# 第三步:返回纯文本(UI 层只需要最终答案)
return resp.answer
📄 core/protocol.py — 统一请求/响应协议
python"""
core/protocol.py — 统一请求/响应数据结构
好处:Streamlit / HTTP API / 企业微信 Bot 都用同一套协议,
只需要各自把参数映射成 ChatRequest,其余不变。
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ChatRequest:
session_id: str # 会话 ID,区分不同用户
message_id: str # 消息 ID(前端生成,用于幂等去重)
trace_id: str # 追踪 ID(前端生成,贯穿整条日志链路)
user_input: str # 用户原始输入
api_key: str # DashScope API Key(可选,不填则走关键词 Demo)
@dataclass(frozen=True)
class ChatResponse:
session_id: str
message_id: str
trace_id: str
answer: str # 最终回复文本
📄 core/id_gen.py — 生成 message_id / trace_id
python"""
core/id_gen.py — ID 生成工具(前端负责生成)
注意:所有 ID 都由前端生成,后端不再生成任何 ID。
- message_id:前端生成,用于幂等去重(同一消息不重复处理)
- trace_id :前端生成,贯穿"入口 → ChatService → 模型/工具 → 存储"全链路,方便排查
前端生成建议:
- message_id:使用 UUID4 或雪花算法
- trace_id:使用 UUID4.hex(32位)或短 UUID
"""
from __future__ import annotations
# 注意:后端不再生成任何 ID,所有 ID 都由前端提供
# 前端示例代码:
# import uuid
# message_id = str(uuid.uuid4())
# trace_id = uuid.uuid4().hex
📄 core/intent.py — 意图识别(Demo 关键词版,生产换 LLM Few-shot)
python"""
core/intent.py — 意图识别模块(生产可运行版)
实现要点:
- 使用 ChatTongyi 做意图识别
- 强约束输出 JSON,便于后续路由/补槽
- 健壮解析:容忍模型偶发输出前后缀文字
说明:ChatService 统一构建 llm 并注入 IntentRecognizer
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
from langchain_core.prompts import ChatPromptTemplate
@dataclass(frozen=True)
class IntentResult:
intents: list[str]
slots: dict[str, Any]
confidence: float
class IntentRecognizer:
def __init__(self, llm) -> None:
self._llm = llm
self._prompt = ChatPromptTemplate.from_messages([
(
"system",
"""你是电商客服意图识别器。你必须只输出 JSON,不要输出任何解释文字。
输出 JSON schema:
{{"intents": ["track_shipping"], "slots": {{"order_id": null, "new_address": null}}, "confidence": 0.0}}
候选 intents:
- track_shipping 物流查询
- change_address 改地址
- refund 退货退款
- complaint 投诉
- general 其它/闲聊
规则:
- intents 可包含多个
- confidence 取值 0~1
- 未提到订单号则 order_id 为 null;未提到新地址则 new_address 为 null""",
),
("human", "用户输入:{input}\nJSON="),
])
def recognize(self, user_input: str) -> IntentResult:
raw = (self._prompt | self._llm).invoke({"input": user_input}).content
data = self._parse_json(raw)
intents = data.get("intents")
if not isinstance(intents, list):
intent = data.get("intent")
intents = [intent] if isinstance(intent, str) else []
intents = [x.strip() for x in intents if isinstance(x, str) and x.strip()] or ["general"]
slots = data.get("slots") if isinstance(data.get("slots"), dict) else {}
confidence = data.get("confidence", 0.0)
try:
confidence = float(confidence)
except Exception:
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
return IntentResult(intents=intents, slots=slots, confidence=confidence)
def _parse_json(self, text: str) -> dict[str, Any]:
if not text:
return {"intents": ["general"], "slots": {}, "confidence": 0.0}
s = text.strip()
try:
return json.loads(s)
except json.JSONDecodeError:
pass
m = re.search(r"\{.*\}", s, re.DOTALL)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
pass
return {"intents": ["general"], "slots": {}, "confidence": 0.0}
📄 core/memory.py — 会话记忆 + 窗口裁剪(原 ChatMessageHistory 逻辑)
python"""
core/memory.py — 智能记忆管理(近期原内容 + 长期摘要)
生产策略:
- 近期对话:保持原内容,保留完整上下文
- 长期记忆:使用摘要形式,控制 Token 成本
- 线程安全:使用 RLock 避免并发写入冲突
"""
from __future__ import annotations
import threading
from typing import Dict, List
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.prompts import ChatPromptTemplate
_lock = threading.RLock()
_store: Dict[str, ChatMessageHistory] = {}
_summary_store: Dict[str, str] = {} # 存储长期摘要
_facts_store: Dict[str, Dict[str, str]] = {} # 存储关键事实(订单号、地址、电话等)
RECENT_WINDOW = 6 # 近期保留的对话轮数
SUMMARY_THRESHOLD = 12 # 超过这个轮数时生成摘要
def get_session_history(session_id: str) -> BaseChatMessageHistory:
"""获取会话历史,如果不存在则创建新的。"""
with _lock:
if session_id not in _store:
_store[session_id] = ChatMessageHistory()
_summary_store[session_id] = "" # 初始化摘要
return _store[session_id]
def _extract_key_facts(text: str) -> Dict[str, str]:
"""从文本中提取关键事实(订单号、地址、电话等)。
生产环境建议:
1. 使用 LLM + 结构化输出提取更精准
2. 使用正则表达式 + 规则引擎
3. 结合业务知识库优化识别
"""
import re
facts = {}
# 订单号模式(常见格式)
order_patterns = [
r'订单[号编号]?[::]?\s*([A-Za-z0-9]{8,20})',
r'order[:\s]*([A-Za-z0-9]{8,20})',
r'([A-Za-z0-9]{12,20})', # 12-20位字母数字组合
]
for pattern in order_patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
facts['order_id'] = match.group(1)
break
# 电话号码模式
phone_patterns = [
r'1[3-9]\d{9}', # 中国手机号
r'\d{3,4}[-\s]?\d{7,8}', # 座机
r'\+86[\s-]?1[3-9]\d{9}', # 带国际区号
]
for pattern in phone_patterns:
match = re.search(pattern, text)
if match:
facts['phone'] = match.group(0)
break
# 地址模式(简单识别)
address_keywords = ['地址', '收货地址', '配送地址', '送货地址']
for keyword in address_keywords:
if keyword in text:
# 提取关键词后的内容作为地址(简化版)
idx = text.find(keyword)
if idx != -1:
address_part = text[idx + len(keyword):].strip(':: ')
if len(address_part) > 5:
facts['address'] = address_part[:100] # 限制长度
break
return facts
def _generate_summary(messages: List, llm=None) -> str:
"""使用 LLM 生成高质量对话摘要(生产级实现)。
摘要策略:
1. 提取关键信息(订单号、地址、问题类型)
2. 保留用户偏好和重要决策
3. 压缩对话内容,控制 Token 成本
4. 保持可读性和结构化
"""
if not messages:
return ""
# 如果没有提供 LLM,使用简化版(兼容性)
if llm is None:
return _generate_simple_summary(messages)
# 准备对话文本
conversation_parts = []
for i, msg in enumerate(messages):
if isinstance(msg, HumanMessage):
conversation_parts.append(f"用户:{msg.content}")
elif isinstance(msg, AIMessage):
conversation_parts.append(f"AI:{msg.content}")
conversation_text = "\n".join(conversation_parts)
# 使用 LLM 生成摘要
summary_prompt = ChatPromptTemplate.from_messages([
("system", """你是对话摘要专家。请将以下对话内容压缩成简洁的摘要,要求:
1. 提取关键信息:订单号、地址、电话、问题类型等
2. 保留重要决策和用户偏好
3. 控制长度在 200 字以内
4. 使用结构化格式,便于后续检索
5. 只输出摘要内容,不要解释
输出格式示例:
[摘要] 用户查询订单12345的物流状态,AI告知已发货,地址为北京市朝阳区,用户要求改地址为上海市浦东新区。"""),
("human", "以下是需要摘要的对话内容:\n\n{conversation}")
])
try:
chain = summary_prompt | llm
result = chain.invoke({"conversation": conversation_text})
summary = result.content.strip()
# 清理输出,确保以 [摘要] 开头
if not summary.startswith("[摘要]"):
summary = f"[摘要] {summary}"
return summary
except Exception as e:
# LLM 调用失败时,回退到简化版
return _generate_simple_summary(messages)
def _generate_simple_summary(messages: List) -> str:
"""简化版摘要生成(回退方案)。"""
summary_parts = []
for i, msg in enumerate(messages[:8]): # 只看前8轮
if isinstance(msg, HumanMessage):
content = msg.content[:50] + "..." if len(msg.content) > 50 else msg.content
summary_parts.append(f"用户:{content}")
elif isinstance(msg, AIMessage):
content = msg.content[:50] + "..." if len(msg.content) > 50 else msg.content
summary_parts.append(f"AI:{content}")
return f"[摘要] {' | '.join(summary_parts)}"
def prepare_memory_for_llm(session_id: str) -> List:
"""为 LLM 准备记忆:关键事实 + 长期摘要 + 近期对话。
返回格式:
[关键事实] + [摘要消息] + [近期对话]
"""
with _lock:
history = get_session_history(session_id)
messages = history.messages
result = []
# 1. 添加关键事实(如果存在)
facts = _facts_store.get(session_id, {})
if facts:
facts_text = " | ".join([f"{k}:{v}" for k, v in facts.items()])
result.append(AIMessage(content=f"[关键事实] {facts_text}"))
if len(messages) <= RECENT_WINDOW * 2:
# 对话较少,全部返回
return result + messages
# 对话较多,需要摘要 + 近期内容
# 2. 添加长期摘要(如果存在)
summary = _summary_store.get(session_id, "")
if summary:
result.append(AIMessage(content=f"[长期对话摘要] {summary}"))
# 3. 添加近期原内容
recent_messages = messages[-(RECENT_WINDOW * 2):]
result.extend(recent_messages)
return result
def trim_history(messages: list, window_size: int = RECENT_WINDOW) -> list:
"""兼容性函数:保持原有接口,内部调用新的记忆管理逻辑。
注意:新架构建议使用 prepare_memory_for_llm() 替代此函数
"""
return messages[-(window_size * 2):]
def add_user_message(session_id: str, message: str, llm=None) -> None:
"""添加用户消息,提取关键事实,并检查是否需要更新摘要。"""
with _lock:
history = get_session_history(session_id)
history.add_user_message(message)
# 提取并更新关键事实
new_facts = _extract_key_facts(message)
if new_facts:
if session_id not in _facts_store:
_facts_store[session_id] = {}
_facts_store[session_id].update(new_facts)
# 检查是否需要生成/更新摘要
messages = history.messages
if len(messages) > SUMMARY_THRESHOLD * 2:
# 生成新的摘要(基于所有历史)
# 传入 LLM 实例以生成高质量摘要
summary = _generate_summary(messages, llm=llm)
_summary_store[session_id] = summary
# 可选:清理过期的原内容,只保留近期 + 摘要
# 这里不清理,让 prepare_memory_for_llm 动态处理
def add_ai_message(session_id: str, message: str, llm=None) -> None:
"""添加 AI 消息,并检查是否需要更新摘要。"""
with _lock:
history = get_session_history(session_id)
history.add_ai_message(message)
# 检查是否需要生成/更新摘要
messages = history.messages
if len(messages) > SUMMARY_THRESHOLD * 2:
# 生成新的摘要(基于所有历史)
# 传入 LLM 实例以生成高质量摘要
summary = _generate_summary(messages, llm=llm)
_summary_store[session_id] = summary
def get_key_facts(session_id: str) -> Dict[str, str]:
"""获取会话的关键事实(供外部调用)。"""
with _lock:
return _facts_store.get(session_id, {}).copy()
def update_key_facts(session_id: str, facts: Dict[str, str]) -> None:
"""手动更新关键事实(供外部调用)。"""
with _lock:
if session_id not in _facts_store:
_facts_store[session_id] = {}
_facts_store[session_id].update(facts)
def clear_key_facts(session_id: str) -> None:
"""清除会话的关键事实。"""
with _lock:
if session_id in _facts_store:
del _facts_store[session_id]
📄 chat_service.py — 核心编排:意图识别 + 记忆注入 + LLM 调用
python"""
chat_service.py — 核心编排层(生产可运行版)
生产版要点:
- 真实构建 ChatTongyi
- 意图识别(JSON)→ 低置信度澄清/转人工 → 路由 system prompt
- 注入裁剪后的 history → 调用 LLM → 写回 history
- logging 输出 trace_id/session_id/message_id/intent/confidence/latency_ms
"""
from __future__ import annotations
import logging
import os
import time
from langchain_community.chat_models import ChatTongyi
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableLambda
# 本地模块导入(IDE 可能提示找不到,但运行时正常)
# 这些是项目内部的模块,确保文件路径正确即可
from config import TONGYI_MAX_TOKENS, TONGYI_MODEL, TONGYI_TEMPERATURE # 配置文件:模型参数
from core.intent import IntentRecognizer # 意图识别模块
from core.memory import add_ai_message, add_user_message, get_session_history, trim_history, prepare_memory_for_llm # 记忆管理模块
from core.protocol import ChatRequest, ChatResponse # 请求/响应协议
logger = logging.getLogger(__name__)
CONFIDENCE_THRESHOLD = 0.6
CONFIDENCE_HUMAN_TRANSFER = 0.3
def _system_prompt_by_intent(intent: str) -> str:
mapping = {
"track_shipping": "你是电商物流查询客服。先要订单号/运单号;如果缺失就追问。",
"change_address": "你是电商改地址客服。先确认是否已发货;需要订单号+新地址。",
"refund": "你是电商退货退款客服。先给3步结论,再给注意事项,最后引导用户提供订单号。",
"complaint": "你是电商投诉处理客服。先安抚,再收集订单号与问题细节,给出处理时效。",
"general": "你是一个友好、简洁的聊天助手。",
}
return mapping.get(intent.strip(), mapping["general"])
def _handle_low_confidence(*, intents: list[str], confidence: float, user_input: str) -> str | None:
normalized = [i.strip() for i in intents if isinstance(i, str) and i.strip()]
primary = normalized[0] if normalized else "general"
# 生产策略:
# 1. 如果已经识别到明确业务意图(非 general),即使置信度偏低也先走业务 prompt
# 2. 对于 general 意图,根据输入内容智能判断是否需要澄清
# 3. 简单查询(如"订单号是多少")即使置信度低也不澄清,直接走 general prompt
if primary != "general":
return None
# 检查是否是简单查询,不需要澄清
simple_query_patterns = [
r'订单号[是的是多少]',
r'我的订单',
r'查询订单',
r'订单情况',
r'物流',
r'快递',
r'地址',
r'电话',
r'退货',
r'投诉',
r'退款',
]
import re
is_simple_query = any(re.search(pattern, user_input) for pattern in simple_query_patterns)
# 如果是简单查询或置信度不低,都不澄清
if is_simple_query or confidence >= CONFIDENCE_THRESHOLD:
return None
# 只有复杂且模糊的查询才澄清
return "抱歉,我没太理解您的需求。您是想:1)查物流进度 2)改收货地址 3)申请退货退款 4)投诉?请告诉我具体需求。"
class ChatService:
def __init__(self) -> None:
# 兜底:入口未配置 logging 时,确保你能看到日志
if not logging.getLogger().handlers:
logging.basicConfig(level=logging.INFO)
self._llm = ChatTongyi(
model=TONGYI_MODEL,
temperature=TONGYI_TEMPERATURE,
max_tokens=TONGYI_MAX_TOKENS,
)
self._intent_recognizer = IntentRecognizer(self._llm)
def handle(self, req: ChatRequest) -> ChatResponse:
"""核心编排层:处理一条用户消息的完整流程。
执行步骤(面试时必讲,体现架构思维):
1️⃣ 环境准备:设置 API Key,开始计时
2️⃣ 意图识别:分析用户输入,提取意图和槽位
3️⃣ 低置信度处理:必要时澄清或转人工
4️⃣ 历史注入:获取会话历史,窗口裁剪控制成本
5️⃣ LLM 调用:根据意图路由到不同 system prompt
6️⃣ 写回历史:保存本轮对话,供下一轮使用
7️⃣ 日志记录:输出 trace 信息,方便排查
"""
t0 = time.time() # 开始计时,用于计算延迟
# 第一步:环境准备(API Key 优先级:UI 输入 > 环境变量)
if req.api_key:
os.environ["DASHSCOPE_API_KEY"] = req.api_key
if not os.getenv("DASHSCOPE_API_KEY"):
raise RuntimeError("DASHSCOPE_API_KEY 未配置:请在 UI 输入或设置环境变量")
try:
# 第二步:意图识别(判断用户想做什么)
intent_result = self._intent_recognizer.recognize(req.user_input)
# 第三步:低置信度处理(必要时澄清或转人工)
low_conf = _handle_low_confidence(intents=intent_result.intents, confidence=intent_result.confidence, user_input=req.user_input)
if low_conf:
self._log(req=req, intent=intent_result.intents, confidence=intent_result.confidence, action="clarify", latency_ms=_ms(t0))
return ChatResponse(req.session_id, req.message_id, req.trace_id, low_conf)
# 第四步:确定主意图(用于路由)
intents = intent_result.intents
primary_intent = intents[0] if intents else "general"
# 第五步:构建 Prompt(根据意图选择不同的 system prompt)
prompt = ChatPromptTemplate.from_messages([
("system", _system_prompt_by_intent(primary_intent)), # 路由到不同业务逻辑
MessagesPlaceholder("history"), # 注入历史对话
("human", "{input}"), # 当前用户输入
])
# 第六步:智能记忆管理(近期原内容 + 长期摘要)
def prepare(input_dict: dict) -> dict:
# 使用新的记忆管理策略:近期原内容 + 长期摘要
memory_messages = prepare_memory_for_llm(input_dict["session_id"])
return {"input": input_dict["input"], "history": memory_messages}
# 第七步:LLM 调用(核心推理)
chain = RunnableLambda(prepare) | (prompt | self._llm) # 组装链路
result = chain.invoke({"input": req.user_input, "session_id": req.session_id})
answer = result.content
# 第八步:写回历史(保存本轮对话,供下一轮使用)
add_user_message(req.session_id, req.user_input, llm=self._llm) # 传入 LLM 实例生成摘要
add_ai_message(req.session_id, answer, llm=self._llm) # 传入 LLM 实例生成摘要
# 第九步:日志记录(输出 trace 信息,方便排查)
self._log(req=req, intent=intents, confidence=intent_result.confidence, action="normal", latency_ms=_ms(t0))
return ChatResponse(req.session_id, req.message_id, req.trace_id, answer)
except Exception as e:
# 异常处理:记录错误日志,重新抛出
self._log(req=req, intent=["error"], confidence=0.0, action="error", latency_ms=_ms(t0), error=str(e))
raise
def _log(
self,
*,
req: ChatRequest,
intent: list[str],
confidence: float,
action: str,
latency_ms: int,
error: str | None = None,
) -> None:
payload = {
"trace_id": req.trace_id,
"session_id": req.session_id,
"message_id": req.message_id,
"intent": intent,
"confidence": confidence,
"action": action,
"latency_ms": latency_ms,
}
if error:
payload["error"] = error
logger.exception(payload)
else:
logger.info(payload)
def _ms(t0: float) -> int:
return int((time.time() - t0) * 1000)
🧩 代码拆解
- _store 字典:为每个
session_id 保存一份 ChatMessageHistory(会话隔离)。
- WINDOW_SIZE + _trim_history:窗口裁剪策略——只保留最近 N 轮对话,避免 History 无限增长导致 Token/成本失控。
- 意图识别链:先输出
intent,再选择不同的 system 提示做路由(第1章:Prompt/Chain 可组合)。
- RunnableLambda(prepare_with_window_trim):在调用前读取
session_id 对应历史,并裁剪后注入到 MessagesPlaceholder("history")。
- 手动写回历史:调用完成后用
history.add_user_message / history.add_ai_message 保存本轮消息,供下一轮追问。
- trace_id/latency:最小观测点,能快速定位“哪一轮慢/答错/路由错”。
⚠️ 你会在这里踩的 3 个坑(来自第1/2章的“高频报错”)
- history 名字必须一致:
MessagesPlaceholder("history") 与 history_messages_key="history" 必须同名。
- session_id 必须传:没传就会全用户共用一个历史,出现“串话”。
- 窗口裁剪要做,且要写回历史:裁剪只影响“注入到 prompt 的历史”,但你仍然需要在每轮调用后把本轮消息保存到
ChatMessageHistory,否则下一轮就没有新上下文。
✅ 如何验证这一段代码“真的生效”(建议按顺序做)
- 会话隔离:开两个浏览器 Tab,同时聊天,确认不会互相引用对方的内容。
- 窗口裁剪生效:连续聊很多轮后,观察模型仍能正确记住“最近几轮”的关键信息,但不会把很久之前的细节都带进 prompt(延迟/成本更稳定)。
意图路由:输入“快递到哪了,顺便改地址”,看日志里 intent 是否稳定输出预期类别。我们的「灵语」不是通用聊天机器人,而是电商客服助手。你可以通过以下典型交互验证意图识别 + 记忆 + 路由的完整链路:
# Round 1:物流查询
用户:快递到哪了?
AI:我先帮你查物流。请提供订单号或运单号。
# Round 2:改地址(多意图)
用户:订单 123,另外我想改地址。
AI:好的,我先返回物流状态,然后再处理改地址。请把新地址发我。
# Round 3:追问上下文
用户:我的订单号是多少
AI:您好,您之前提到的订单号是 123。如果您需要进一步处理退货或其他问题,请确认是否为该订单号,或提供新的订单号以便我为您查询和协助。
# Round 4:退货流程
用户:那我要退货怎么走?
AI:退货流程如下:1)在订单页点击申请退货 → 2)填写退货原因并上传照片 → 3)等待审核 → 4)寄回商品 → 5)确认收货后退款。请提供你的订单号,我帮你确认当前状态。{"trace_id": "9f1a...", "session_id": "abc...", "intent": "track_shipping", "latency_ms": 820}intent 是否随输入变化:track_shipping / change_address / return_refund / chitchatintent=track_shippingintent=change_addressintent=return_refundintent=chitchattrack_shipping);进阶版可升级为输出 JSON(intent 数组 + slots + confidence)再做补槽追问。
system prompt(物流/改地址/退货/闲聊),让回答更"像业务系统"。session_id 做 key,每个会话对应一份 ChatMessageHistory,避免串话。history.add_user_message / history.add_ai_message 保存本轮对话,供下一轮追问。trace_id/session_id/intent/latency_ms,定位“答错/路由错/慢/贵”。在课件的完整项目中,还实现了许多进阶功能,你可以作为本章的扩展练习:
st.session_state["messages"] 存为 JSON 文件下载。qwen-turbo / qwen-plus / qwen-max。.stream() 接口,边生成边展示回答。面试官最关心的不是"你用了什么框架",而是"你解决了什么问题、做了什么取舍"。 下面把本项目 5 大难点 拆开讲。
ChatMessageHistory + 窗口裁剪(只注入最近 N 轮)先把成本稳定住;进阶可再加“早期摘要 + 关键事实结构化存储”。
session:{id}:messages,过期时间 24h。# 会话写入(Pipeline + Lua 保证原子性)
def append_message(session_id: str, msg: Message):
lua = '''
local key = KEYS[1]
local payload = ARGV[1]
local ttl = tonumber(ARGV[2])
redis.call('LPUSH', key, payload)
redis.call('EXPIRE', key, ttl)
return redis.call('LLEN', key)
'''
return redis_client.eval(lua, 1,
f'session:{session_id}:messages',
msg.to_json(), 86400)
# 会话读取(本地缓存兜底)
def get_messages(session_id: str) -> List[Message]:
cache_key = f'session:{session_id}:msg_cache'
msgs = local_cache.get(cache_key)
if msgs: return msgs
raw = redis_client.lrange(f'session:{session_id}:messages', 0, -1)
msgs = [Message.from_json(b) for b in raw]
local_cache.put(cache_key, msgs, ttl=300)
return msgs{
"trace_id": "t-20260224-7f3a1b9c",
"session_id": "s-uuid4",
"user_id": "u-123456",
"request_ts": "2026-02-24T12:34:56.789Z",
"intent": {
"name": "track_shipping",
"confidence": 0.93,
"slots": {"order_id": "ORD-202602001"},
"prompt_version": "v2.3",
"model": "qwen-plus"
},
"routing": {
"target_chain": "shipping_chain",
"fallback_reason": null,
"latency_ms": 12
},
"execution": {
"chain_name": "shipping_chain",
"input_tokens": 128,
"output_tokens": 64,
"llm_latency_ms": 340
},
"post": {
"final_answer": "您的快递已签收",
"safety_score": 0.98,
"rules_applied": ["address_mask", "order_status_check"]
}
}./replay --trace t-20260224-7f3a1b9c --step intent --prompt-version v2.4以下提供完整版和精简版两个版本,可根据简历篇幅选用,微调后直接粘贴。
你已掌握「灵语」智能对话中枢的开发能力。 接下来,进入阶段 2:「智阅」企业知识库助手, 学习如何将 AI 与文档知识库结合,打造更智能的企业知识问答系统。