"""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