Files
group_xinghuo_jinrong/app/service/agent_service.py
T

328 lines
12 KiB
Python
Raw Normal View History

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