Files
XingHuo/app/service/agent_service.py
T

242 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 注入,不依赖外网。
"""
from __future__ import annotations
import threading
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 llm_node(state: ChatState) -> dict[str, Any]:
"""组装 messages 后调 LLM;未配置 key 时降级(不抛异常,演示链路不断)。
降级回复携带 Tool 查询摘要(T-04:无 LLM 时查询不白跑,结果直出)。
"""
messages = _compose_messages(state)
if not settings.deepseek_api_key:
summaries = [tool_service.summarize(r) for r in (state.get("tool_results") or [])]
reply = (
f"{_DEGRADED_PREFIX}\n" + "\n".join(summaries)
if summaries
else f"{_DEGRADED_PREFIX}已收到您的消息:{state['user_message']}"
)
return {"messages": messages + [AIMessage(content=reply)], "reply": reply}
llm = _get_llm()
result = llm.invoke(messages)
return {"messages": messages + [result], "reply": result.content}
def guard_node(state: ChatState) -> dict[str, Any]:
"""合规护栏:对外角色(customer/risk,未知类型 fail-safe 按最严口径)附免责声明。"""
external = state["agent_type"] in ("customer", "risk") or state["agent_type"] not in _SYSTEM_PROMPTS
if external:
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 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(
{
"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": [],
}
)
return {
"reply": final["reply"],
"has_disclaimer": final["has_disclaimer"],
"tool_results": final.get("tool_results") or [],
}
def reset_cache() -> None:
"""测试隔离出口:清空图与 LLM 单例缓存。"""
global _graph, _llm
with _graph_lock:
_graph = None
_llm = None