Files
group_fqcd_jr/app/core/contracts.py
T
lzf_0626 dbe7285c1c 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。
2026-09-10 22:01:43 +08:00

164 lines
4.7 KiB
Python

from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class AgentRequestMetadata(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
locale: str | None = None
client_version: str | None = None
ui_entry: str | None = None
class ConversationTurn(BaseModel):
"""会话中的一轮对话(短期记忆)。
只保留角色与正文:模型用它解析指代("那它风险高吗"里的"它"指哪只基金),
不需要意图、置信度这类内部字段——把内部字段一并喂给模型既增加噪声,
也扩大了"模型看到不该看的东西"的面。
"""
model_config = ConfigDict(frozen=True)
role: Literal["user", "assistant"]
content: str
class AgentRequest(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
agent_type: str
message: str
session_id: str
idempotency_key: str
metadata: AgentRequestMetadata = Field(default_factory=AgentRequestMetadata)
# 本会话中**本次之前**的对话,按时间正序(旧 → 新)。
# 默认空元组:既有构造点(API 受理路径、单测、验收脚本)无需改动即可继续工作。
history: tuple[ConversationTurn, ...] = ()
@field_validator("message")
@classmethod
def message_must_not_be_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("message must not be blank")
return value
@field_validator("idempotency_key")
@classmethod
def idempotency_key_must_be_valid(cls, value: str) -> str:
if not 16 <= len(value) <= 128 or not value.replace("-", "").replace("_", "").isalnum():
raise ValueError("idempotency_key must be 16-128 alphanumeric characters")
return value
class RequestContext(BaseModel):
model_config = ConfigDict(frozen=True)
user_id: str
trace_id: str
roles: tuple[str, ...] = ()
customer_ids: tuple[str, ...] = ()
data_scope: str = "self"
portal: str = "api"
clarification_round: int = Field(default=0, ge=0, le=10)
permissions: tuple[str, ...] = ()
permission_scopes: dict[str, str] = Field(default_factory=dict)
class AgentDefinition(BaseModel):
model_config = ConfigDict(frozen=True)
agent_type: str
version: str
allowed_tools: tuple[str, ...] = ()
allowed_roles: tuple[str, ...] = ()
allowed_portals: tuple[str, ...] = ()
supported_intents: tuple[str, ...] = ("general",)
class ResolvedAgentConfig(BaseModel):
model_config = ConfigDict(frozen=True)
config_version: str
prompt_version: str
model_endpoint: str
allowed_tools: tuple[str, ...] = ()
timeout_seconds: int = Field(default=60, gt=0)
release_id: int | None = None
allowed_tools_by_intent: dict[str, tuple[str, ...]] = Field(default_factory=dict)
negative_rules: tuple[tuple[str, str], ...] = ()
class RecalledMemory(BaseModel):
model_config = ConfigDict(frozen=True)
memory_uuid: str
customer_id: str
content: str
class SourceReference(BaseModel):
model_config = ConfigDict(frozen=True)
source_type: Literal["knowledge", "memory", "relationship", "tool"]
source_id: str
title: str | None = None
score: float | None = Field(default=None, ge=0, le=1)
class ToolCallRecord(BaseModel):
model_config = ConfigDict(frozen=True)
tool_name: str
status: Literal["succeeded", "failed", "denied"]
input_summary: dict[str, Any] = Field(default_factory=dict)
output_summary: dict[str, Any] = Field(default_factory=dict)
class IntentResult(BaseModel):
model_config = ConfigDict(frozen=True)
intent: str
confidence: float = Field(ge=0, le=1)
needs_clarification: bool = False
class CoreResult(BaseModel):
model_config = ConfigDict(frozen=True)
text: str
intent: IntentResult | None = None
source_references: tuple[SourceReference, ...] = ()
tool_calls: tuple[ToolCallRecord, ...] = ()
transfer_required: bool = False
transfer_reason: str | None = None
class AgentResult(BaseModel):
model_config = ConfigDict(frozen=True)
run_id: str
result: CoreResult
usage: dict[str, int] = Field(default_factory=dict)
class RunProgressEvent(BaseModel):
model_config = ConfigDict(frozen=True)
event_type: Literal["start", "tools", "delta", "replace", "done", "error"]
run_id: str
payload: dict[str, Any] = Field(default_factory=dict)
@dataclass(frozen=True)
class DomainEvent:
event_id: str
event_type: str
aggregate_type: str
aggregate_id: str
trace_id: str
payload: dict[str, Any]
occurred_at: datetime