feat: 短期会话记忆(多轮指代可解析)

一、此前的缺口
方案 §2.2 要求会话短期记忆,但底座**没有任何加载历史消息的代码**:conversation_message
存了全部消息、svc_conversation_session 只在计数,而 run 执行时只拿到当前这一条消息。
后果是客户问"那它风险高吗"时"它"无从对应,向量检索落到无关内容、整条回答走兜底——
多轮对话事实上不可用。

二、实现
1. 契约:AgentRequest 新增 `history: tuple[ConversationTurn, ...] = ()`(默认空元组,
   既有构造点无需改动)。ConversationTurn 只保留 role 与正文,不把意图/置信度等内部字段
   喂给模型——既减少噪声,也收窄"模型看到不该看的东西"的面。
2. 加载:WorkerRuntime._execute_claimed 构造 AgentRequest 时加载本会话此前的对话
   (上限 10 轮,按 id 正序)。`before_message_id` 排除本轮请求消息本身,否则模型会在
   上下文里看到自己的问题被重复一遍。
3. 使用:客服 Agent 构造检索查询时,把最近两轮**客户**消息与当前问题拼接。只取客户的
   话、不取 Agent 自己的回答——把后者拼进来会让检索偏向自己上一轮的说法,而客户的真实
   意图可能已经在下一句里被修正。

三、两个刻意的取舍
· **不引入 Redis 双写**:方案 §2.2 设想用 Redis 列表,但消息在受理时已落库,再同步一份
  只会带来不一致与 TTL 管理成本,换来的仅是一次索引查询的节省。这里取等价语义
  (同样"最近若干轮、超出即截断")而不复制存储。
· **按条数截断而非 token**:没有与模型一致的分词器,按 token 截断只能估算、边界会随实现
  漂移;按条数是确定性的,宁可少给几轮,也不给一个不稳定的边界。

四、实测(同一会话两轮)
· 第 1 轮"南方季季盈90天的起投金额是多少" → 正确返回该产品表格(R2、起投 1 万元等);
· 第 2 轮只说"那它风险高吗"(不含任何产品名)→ 仍正确检索到同一产品并答出风险等级 R2、
  业绩比较基准与投资范围;此前这类提问必然走兜底;
· ruff 通过、mypy 113 文件无错、unit+contract 447 passed。
This commit is contained in:
2026-09-10 22:01:43 +08:00
parent d4836ed4df
commit dbe7285c1c
3 changed files with 75 additions and 1 deletions
@@ -114,7 +114,7 @@ class CustomerServiceAgent(BaseAgent):
try:
output = await self.call_tool(
TOOL_NAME,
{"query": request.message[:500], "top_k": TOP_K},
{"query": self._search_query(request), "top_k": TOP_K},
intent=intent,
context=context,
)
@@ -205,6 +205,20 @@ class CustomerServiceAgent(BaseAgent):
row.user_prompt_template or DEFAULT_CHITCHAT_TEMPLATE,
)
@staticmethod
def _search_query(request: AgentRequest) -> str:
"""构造交给检索的查询串:把最近几轮**客户**说过的话与当前问题拼在一起。
为什么必须带上文:检索是向量匹配,只看当前这一句时,"那它风险高吗"里的"它"
无从对应,检索会落到无关内容上、进而整条回答走兜底。把上文一并向量化,
检索才能落到上文提到的那个产品上。
为什么只取客户的话、不取 Agent 自己的回答:把 Agent 的措辞也拼进来会让检索
偏向自己上一轮的说法,而客户的真实意图可能已经在下一句里被修正过。
"""
recent_user = [turn.content for turn in request.history if turn.role == "user"][-2:]
return " ".join([*recent_user, request.message])[:500]
# ---- 出口三:引导人工客服(不做工单,只回话并留痕) ----
def _guide_to_human(self, reason: str) -> CoreResult: