- 更新 chat.py,整合四个 Agent 的统一入口,增加输入防护与会话校验逻辑。 - 扩展 agent_service.py,加入工具节点与 LLM 交互,支持意图匹配与合规护栏。 - 改进 memory_service.py,优化会话窗口管理,支持 Redis 与 MySQL 的数据同步。 此更新提升了对话系统的安全性与可扩展性,确保了会话数据的可靠性与合规性。
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""Agent 编排(T-07 骨架 / T-04 Tool 节点):LangGraph StateGraph + DeepSeek 对话。
|
||
|
||
链路(FLOW §2):tool(意图匹配→归属校验→只读查询→agent_tool_call 落库)
|
||
→ llm(DeepSeek,工具结果注入上下文)→ guard(合规护栏)。四 Agent 能力
|
||
边界(FLOW §4 与 MEMORY 禁止项)由 system prompt 固化:客户无投资建议/
|
||
收益承诺/自动下单;代理人草稿不外发;分析只读;风控辅助不自动处置。
|
||
|
||
Tool 节点(T-04):一期关键词意图(tool_service.match_intent)仅对
|
||
customer/advisor 分支查 Core RO(持仓/流水/L0);customer_id 由会话注入,
|
||
不来自 LLM。risk 分支四个风控 Tool 与 LLM intent 归 C1/C2(开发计划)。
|
||
|
||
LLM 未配置(DEEPSEEK_API_KEY 为空)时降级:回复携带 Tool 查询摘要
|
||
(演示链路不断且查询不白跑);单测经 FakeLLM 注入,不依赖外网。
|
||
|
||
方案 C(SSE):`stream_chat` 为流式入口——Tool 节点同步跑完后逐块产出
|
||
LLM 文本,落库由 api 层在收完 done 后统一写(断连整轮不落消息)。
|
||
免责判定抽 `needs_disclaimer`,首帧 meta 与落库文本共用同一口径。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
from collections.abc import Iterator
|
||
from typing import Annotated, Any, TypedDict
|
||
|
||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
|
||
from langgraph.graph import END, START, StateGraph
|
||
from operator import add
|
||
|
||
from app.config.settings import settings
|
||
from app.service import tool_service
|
||
|
||
# 客户/对外口径的固定免责声明(随回复文本尾部输出;G-08 阻断响应另有两要素)
|
||
CHAT_DISCLAIMER = "以上内容由 AI 生成,仅供业务参考,不构成投资建议。"
|
||
|
||
# LLM 未配置时的降级回复(明确标注非模型生成,演示链路不断)
|
||
_DEGRADED_PREFIX = "(LLM 未配置:请在 .env 设置 DEEPSEEK_API_KEY 后重启)"
|
||
|
||
# 四 Agent 能力边界 system prompt(FLOW §4;MEMORY 禁止项固化)
|
||
_SYSTEM_PROMPTS: dict[str, str] = {
|
||
"customer": (
|
||
"你是客户财富智能管家。仅服务当前登录客户本人;不得提供投资建议、"
|
||
"收益承诺或自动下单;事实性数据须注明来源,无法回答时如实告知。"
|
||
),
|
||
"advisor": (
|
||
"你是代理人助手,服务理财顾问。仅可讨论名下客户(归属经平台校验);"
|
||
"生成的草稿内容不会自动外发给客户;引用数据须可溯源。"
|
||
),
|
||
"analyst": (
|
||
"你是数据分析助手。仅做只读查询与聚合分析,不写任何画像/预警/业务数据。"
|
||
),
|
||
"risk": (
|
||
"你是风控监测助手。可查询预警台账与客户监测信息,输出仅供参考,"
|
||
"最终处置须经风控专员通过处置接口人工完成;不得自动处置预警。"
|
||
),
|
||
}
|
||
|
||
|
||
class ChatState(TypedDict):
|
||
"""对话图状态(messages 由各节点以 add 归并,避免覆盖历史)。
|
||
|
||
T-04 Tool 上下文:session_id/trace_id 供 agent_tool_call 落库;actor 为
|
||
api/chat 展开的鉴权字段({actor_id, roles, token_type});customer_id 为
|
||
会话绑定客户(归属校验后的值,Tool 查询主体恒取此处)。
|
||
"""
|
||
|
||
agent_type: str
|
||
history: list[dict]
|
||
user_message: str
|
||
messages: Annotated[list[BaseMessage], add]
|
||
reply: str
|
||
has_disclaimer: bool
|
||
session_id: str | None
|
||
trace_id: str | None
|
||
actor: dict[str, Any] | None
|
||
customer_id: str | None
|
||
tool_results: list[dict]
|
||
|
||
|
||
def _compose_messages(state: ChatState) -> list[BaseMessage]:
|
||
"""system(角色边界)+ 工具结果(有则注入)+ 历史窗口 + 本轮用户消息。"""
|
||
msgs: list[BaseMessage] = [
|
||
SystemMessage(content=_SYSTEM_PROMPTS.get(state["agent_type"], _SYSTEM_PROMPTS["customer"]))
|
||
]
|
||
tool_text = tool_service.context_text(state.get("tool_results") or [])
|
||
if tool_text:
|
||
msgs.append(SystemMessage(content=tool_text))
|
||
for m in state["history"]:
|
||
role = m.get("role")
|
||
content = m.get("content", "")
|
||
if role == "user":
|
||
msgs.append(HumanMessage(content=content))
|
||
elif role == "assistant":
|
||
msgs.append(AIMessage(content=content))
|
||
msgs.append(HumanMessage(content=state["user_message"]))
|
||
return msgs
|
||
|
||
|
||
def tool_node(state: ChatState) -> dict[str, Any]:
|
||
"""Tool 节点(T-04 + C2):意图匹配 → tool_service.run_tool(校验+落库)。
|
||
|
||
守卫(C2 放宽):会话与 actor 恒为必需;requires_customer=False 的 Tool
|
||
(如 alert_query 全量待审)允许无绑定客户运行——否则 risk_officer 查
|
||
"今天有多少待审预警"(A-6)会被 customer_id 空缺拦死。requires_customer
|
||
为 True 的 Tool 仍需绑定客户(无则空转,由 run_tool 判
|
||
TOOL_BLOCKED_NO_CUSTOMER)。
|
||
"""
|
||
if not (state.get("session_id") and state.get("actor")):
|
||
return {"tool_results": []}
|
||
tool_name = tool_service.match_intent(state["agent_type"], state["user_message"])
|
||
if tool_name is None:
|
||
return {"tool_results": []}
|
||
spec = tool_service.get_registered_tool(tool_name)
|
||
if spec is None:
|
||
return {"tool_results": []}
|
||
if spec.get("requires_customer") and not state.get("customer_id"):
|
||
return {"tool_results": []}
|
||
# 带参 Tool(T21 知识库):spec 白名单声明了 "query" → 由代码注入用户消息
|
||
# (一期 tool_input 恒来自代码,不来自 LLM 输出,与 T-04 口径一致)
|
||
whitelist = tuple(spec.get("param_whitelist") or ())
|
||
tool_input = {"query": state["user_message"]} if "query" in whitelist else None
|
||
record = tool_service.run_tool(
|
||
tool_name=tool_name,
|
||
agent_type=state["agent_type"],
|
||
actor=state["actor"],
|
||
customer_id=state.get("customer_id") or "",
|
||
tool_input=tool_input,
|
||
session_id=state["session_id"],
|
||
trace_id=state.get("trace_id"),
|
||
)
|
||
return {"tool_results": [record]}
|
||
|
||
|
||
def _degraded_reply(state: ChatState) -> str:
|
||
"""无 LLM key 时的降级回复(T-04:查询不白跑,摘要直出)。"""
|
||
summaries = [tool_service.summarize(r) for r in (state.get("tool_results") or [])]
|
||
if summaries:
|
||
return f"{_DEGRADED_PREFIX}\n" + "\n".join(summaries)
|
||
return f"{_DEGRADED_PREFIX}已收到您的消息:{state['user_message']}"
|
||
|
||
|
||
def llm_node(state: ChatState) -> dict[str, Any]:
|
||
"""组装 messages 后调 LLM;未配置 key 时降级(不抛异常,演示链路不断)。
|
||
|
||
降级回复携带 Tool 查询摘要(T-04:无 LLM 时查询不白跑,结果直出)。
|
||
"""
|
||
messages = _compose_messages(state)
|
||
if not settings.deepseek_api_key:
|
||
reply = _degraded_reply(state)
|
||
return {"messages": messages + [AIMessage(content=reply)], "reply": reply}
|
||
llm = _get_llm()
|
||
result = llm.invoke(messages)
|
||
return {"messages": messages + [result], "reply": result.content}
|
||
|
||
|
||
def needs_disclaimer(agent_type: str) -> bool:
|
||
"""是否需附免责声明(与 guard_node 同口径,未知类型 fail-safe 按最严)。
|
||
|
||
方案 C(SSE):流式下声明无法再拼在尾部——首帧 meta 先下发声明文本供
|
||
前端常驻,落库文本仍按 guard_node 口径拼尾部,两处判定共用此函数,
|
||
避免"首帧说有、落库说无"的口径漂移。
|
||
"""
|
||
return agent_type in ("customer", "risk") or agent_type not in _SYSTEM_PROMPTS
|
||
|
||
|
||
def guard_node(state: ChatState) -> dict[str, Any]:
|
||
"""合规护栏:对外角色(customer/risk,未知类型 fail-safe 按最严口径)附免责声明。"""
|
||
if needs_disclaimer(state["agent_type"]):
|
||
return {"reply": f"{state['reply']}\n\n{CHAT_DISCLAIMER}", "has_disclaimer": True}
|
||
return {"has_disclaimer": False}
|
||
|
||
|
||
def build_graph():
|
||
"""StateGraph:START → tool → llm → guard → END(T-04 接入 Tool 节点)。"""
|
||
graph = StateGraph(ChatState)
|
||
graph.add_node("tool", tool_node)
|
||
graph.add_node("llm", llm_node)
|
||
graph.add_node("guard", guard_node)
|
||
graph.add_edge(START, "tool")
|
||
graph.add_edge("tool", "llm")
|
||
graph.add_edge("llm", "guard")
|
||
graph.add_edge("guard", END)
|
||
return graph.compile()
|
||
|
||
|
||
_graph = None
|
||
_graph_lock = threading.Lock()
|
||
_llm: Any | None = None
|
||
_llm_lock = threading.Lock()
|
||
|
||
|
||
def _get_graph():
|
||
global _graph
|
||
with _graph_lock:
|
||
if _graph is None:
|
||
_graph = build_graph()
|
||
return _graph
|
||
|
||
|
||
def _get_llm() -> Any:
|
||
"""DeepSeek 经 langchain-openai 兼容接口(懒构造加锁;测试注入 _llm)。"""
|
||
global _llm
|
||
with _llm_lock:
|
||
if _llm is None:
|
||
from langchain_openai import ChatOpenAI
|
||
|
||
_llm = ChatOpenAI(
|
||
model="deepseek-chat",
|
||
api_key=settings.deepseek_api_key,
|
||
base_url=settings.deepseek_base_url,
|
||
temperature=0.3,
|
||
)
|
||
return _llm
|
||
|
||
|
||
def _base_state(
|
||
agent_type: str,
|
||
history: list[dict],
|
||
user_message: str,
|
||
*,
|
||
session_id: str | None,
|
||
trace_id: str | None,
|
||
actor: dict[str, Any] | None,
|
||
customer_id: str | None,
|
||
) -> ChatState:
|
||
"""图初始状态(chat 与 stream_chat 共用;Tool 上下文一致)。"""
|
||
return {
|
||
"agent_type": agent_type,
|
||
"history": history,
|
||
"user_message": user_message,
|
||
"messages": [],
|
||
"reply": "",
|
||
"has_disclaimer": False,
|
||
"session_id": session_id,
|
||
"trace_id": trace_id,
|
||
"actor": actor,
|
||
"customer_id": customer_id,
|
||
"tool_results": [],
|
||
}
|
||
|
||
|
||
def chat(
|
||
agent_type: str,
|
||
history: list[dict],
|
||
user_message: str,
|
||
*,
|
||
session_id: str | None = None,
|
||
trace_id: str | None = None,
|
||
actor: dict[str, Any] | None = None,
|
||
customer_id: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""对话入口(T-06 api/chat 调用):返回 {reply, has_disclaimer}。
|
||
|
||
T-04:session 上下文(session_id/trace_id/actor/customer_id)可选传入;
|
||
缺省时 Tool 节点空转(既有用例与纯闲聊不受影响)。
|
||
"""
|
||
final = _get_graph().invoke(
|
||
_base_state(
|
||
agent_type,
|
||
history,
|
||
user_message,
|
||
session_id=session_id,
|
||
trace_id=trace_id,
|
||
actor=actor,
|
||
customer_id=customer_id,
|
||
)
|
||
)
|
||
return {
|
||
"reply": final["reply"],
|
||
"has_disclaimer": final["has_disclaimer"],
|
||
"tool_results": final.get("tool_results") or [],
|
||
}
|
||
|
||
|
||
def stream_chat(
|
||
agent_type: str,
|
||
history: list[dict],
|
||
user_message: str,
|
||
*,
|
||
session_id: str | None = None,
|
||
trace_id: str | None = None,
|
||
actor: dict[str, Any] | None = None,
|
||
customer_id: str | None = None,
|
||
) -> Iterator[tuple[str, str]]:
|
||
"""流式对话(方案 C):yield ("delta", 文本块)... → ("done", 完整正文)。
|
||
|
||
与 chat() 同口径:Tool 节点先同步跑完(落 agent_tool_call + 结果注入
|
||
上下文),再推 LLM 文本——Tool 不流式,因为要留痕且结果是 LLM 输入。
|
||
未配置 key 时降级整块输出(契约不变,前端无需特判)。
|
||
|
||
**落库由调用方(api/chat)在收完 done 后统一写**:中途异常/客户端断连
|
||
→ 整轮消息不落(Tool 留痕已落,可审计),不产生半截内容污染历史窗口。
|
||
异常上抛由路由层转 SSE error 事件,保证前端拿到的是结构化错误而非
|
||
断流。
|
||
"""
|
||
state = _base_state(
|
||
agent_type,
|
||
history,
|
||
user_message,
|
||
session_id=session_id,
|
||
trace_id=trace_id,
|
||
actor=actor,
|
||
customer_id=customer_id,
|
||
)
|
||
state["tool_results"] = tool_node(state).get("tool_results") or []
|
||
messages = _compose_messages(state)
|
||
if not settings.deepseek_api_key:
|
||
reply = _degraded_reply(state)
|
||
yield ("delta", reply)
|
||
yield ("done", reply)
|
||
return
|
||
llm = _get_llm()
|
||
buf: list[str] = []
|
||
for chunk in llm.stream(messages): # DeepSeek / OpenAI 兼容:逐 chunk 文本
|
||
text = getattr(chunk, "content", None) or ""
|
||
if text:
|
||
buf.append(text)
|
||
yield ("delta", text)
|
||
yield ("done", "".join(buf))
|
||
|
||
|
||
def reset_cache() -> None:
|
||
"""测试隔离出口:清空图与 LLM 单例缓存。"""
|
||
global _graph, _llm
|
||
with _graph_lock:
|
||
_graph = None
|
||
_llm = None
|