hxf/backend/th_agenter/services/agent/langgraph_agent_service.py

737 lines
34 KiB
Python
Raw Normal View History

2025-12-04 14:48:38 +08:00
"""LangGraph Agent service with tool calling capabilities."""
import asyncio
from typing import List, Dict, Any, Optional, AsyncGenerator
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.tools import tool
2025-12-16 13:55:16 +08:00
from langchain.chat_models import init_chat_model
# from langgraph.prebuilt import create_react_agent
2025-12-04 14:48:38 +08:00
from pydantic import BaseModel, Field
from .base import ToolRegistry
from th_agenter.services.tools import WeatherQueryTool, TavilySearchTool, DateTimeTool
from ..postgresql_tool_manager import get_postgresql_tool
from ...core.config import get_settings
from ..agent_config import AgentConfigService
2025-12-16 13:55:16 +08:00
from th_agenter.services.mcp.mcp_dynamic_tools import load_mcp_tools
from loguru import logger
2025-12-04 14:48:38 +08:00
class LangGraphAgentConfig(BaseModel):
"""LangGraph Agent configuration."""
model_name: str = Field(default="gpt-3.5-turbo")
model_provider: str = Field(default="openai")
base_url: Optional[str] = Field(default=None)
api_key: Optional[str] = Field(default=None)
enabled_tools: List[str] = Field(default_factory=lambda: [
"calculator", "weather", "search", "file", "image"
])
max_iterations: int = Field(default=10)
temperature: float = Field(default=0.7)
max_tokens: int = Field(default=1000)
system_message: str = Field(
default="""你是一个有用的AI助手可以使用各种工具来帮助用户解决问题。
重要规则
1. 工具调用失败时必须仔细分析失败原因特别是参数格式问题
3. 在重新调用工具前先解释上次失败的原因和改进方案
4. 确保每个工具调用的参数格式严格符合工具的要求 """
)
verbose: bool = Field(default=True)
class LangGraphAgentService:
2025-12-16 13:55:16 +08:00
"""LangGraph Agent service using low-level LangGraph graph (React pattern)."""
2025-12-04 14:48:38 +08:00
def __init__(self, db_session=None):
self.settings = get_settings()
self.tool_registry = ToolRegistry()
self.config = LangGraphAgentConfig()
self.tools = []
self.db_session = db_session
self.config_service = AgentConfigService(db_session) if db_session else None
self._initialize_tools()
self._load_config()
2025-12-16 13:55:16 +08:00
self._create_react_agent()
2025-12-04 14:48:38 +08:00
def _initialize_tools(self):
"""Initialize available tools."""
2025-12-16 13:55:16 +08:00
try:
dynamic_tools = load_mcp_tools()
except Exception as e:
logger.warning(f"加载 MCP 动态工具失败,使用本地工具回退: {e}")
dynamic_tools = []
# Always keep DateTimeTool locally
base_tools = [DateTimeTool()]
if dynamic_tools:
self.tools = dynamic_tools + base_tools
logger.info(f"LangGraph 绑定 MCP 动态工具: {[t.name for t in dynamic_tools]}")
else:
# Fallback to local weather/search when MCP not available
self.tools = [
WeatherQueryTool(),
TavilySearchTool(),
] + base_tools
logger.info("MCP 不可用,已回退到本地 Weather/Search 工具")
2025-12-04 14:48:38 +08:00
def _load_config(self):
"""Load configuration from database if available."""
if self.config_service:
try:
db_config = self.config_service.get_active_config()
if db_config:
# Update config with database values
config_dict = db_config.config_data
for key, value in config_dict.items():
if hasattr(self.config, key):
setattr(self.config, key, value)
logger.info("Loaded configuration from database")
except Exception as e:
logger.warning(f"Failed to load config from database: {e}")
2025-12-16 13:55:16 +08:00
def _create_react_agent(self):
"""Create LangGraph agent using low-level StateGraph with explicit nodes/edges."""
2025-12-04 14:48:38 +08:00
try:
# Initialize the model
llm_config = get_settings().llm.get_current_config()
self.model = init_chat_model(
model=llm_config['model'],
model_provider='openai',
temperature=llm_config['temperature'],
max_tokens=llm_config['max_tokens'],
base_url= llm_config['base_url'],
api_key=llm_config['api_key']
)
2025-12-16 13:55:16 +08:00
# Bind tools to the model so it can propose tool calls
try:
self.bound_model = self.model.bind_tools(self.tools)
except Exception as e:
logger.warning(f"Failed to bind tools to model, tool calling may not work: {e}")
self.bound_model = self.model
# Build low-level React graph: State -> agent -> tools -> agent ... until stop
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langchain_core.messages import ToolMessage, BaseMessage
from typing import Annotated
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[List[BaseMessage], add_messages]
# Node: call the model
def agent_node(state: AgentState) -> AgentState:
messages = state["messages"]
# Optionally include a system instruction at the start for first turn
if messages and messages[0].__class__.__name__ != 'SystemMessage':
# Keep user history untouched; rely on upstream to include system if desired
pass
ai = self.bound_model.invoke(messages)
return {"messages": [ai]}
# Node: execute tools requested by the last AI message
def tools_node(state: AgentState) -> AgentState:
messages = state["messages"]
last = messages[-1]
outputs: List[ToolMessage] = []
try:
tool_calls = getattr(last, 'tool_calls', []) or []
tool_map = {t.name: t for t in self.tools}
for call in tool_calls:
name = call.get('name') if isinstance(call, dict) else getattr(call, 'name', None)
args = call.get('args') if isinstance(call, dict) else getattr(call, 'args', {})
call_id = call.get('id') if isinstance(call, dict) else getattr(call, 'id', '')
if name in tool_map:
try:
result = tool_map[name].invoke(args)
except Exception as te:
result = f"Tool {name} execution error: {te}"
else:
result = f"Unknown tool: {name}"
outputs.append(ToolMessage(content=str(result), tool_call_id=call_id))
except Exception as e:
outputs.append(ToolMessage(content=f"Tool execution error: {e}", tool_call_id=""))
return {"messages": outputs}
# Router: decide next step after agent node
def route_after_agent(state: AgentState) -> str:
last = state["messages"][-1]
finish_reason = None
try:
meta = getattr(last, 'response_metadata', {}) or {}
finish_reason = meta.get('finish_reason')
except Exception:
finish_reason = None
# If the model decided to call tools, continue to tools node
if getattr(last, 'tool_calls', None):
return "tools"
# Otherwise, end
return END
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tools_node)
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", route_after_agent, {"tools": "tools", END: END})
graph.add_edge("tools", "agent")
2025-12-04 14:48:38 +08:00
2025-12-16 13:55:16 +08:00
# Compile graph and store as self.agent for compatibility with existing code
self.react_agent = graph.compile()
2025-12-04 14:48:38 +08:00
2025-12-16 13:55:16 +08:00
logger.info("LangGraph low-level React agent created successfully")
2025-12-04 14:48:38 +08:00
except Exception as e:
logger.error(f"Failed to create agent: {str(e)}")
raise
def _format_tools_info(self) -> str:
"""Format tools information for the prompt."""
tools_info = []
for tool_name in self.config.enabled_tools:
tool = self.tool_registry.get_tool(tool_name)
if tool:
params_info = []
for param in tool.get_parameters():
params_info.append(f" - {param.name} ({param.type.value}): {param.description}")
tool_info = f"**{tool.get_name()}**: {tool.get_description()}"
if params_info:
tool_info += "\n" + "\n".join(params_info)
tools_info.append(tool_info)
return "\n\n".join(tools_info)
async def chat(self, message: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
"""Process a chat message using LangGraph."""
try:
logger.info(f"Starting chat with message: {message[:100]}...")
# Convert chat history to messages
messages = []
if chat_history:
for msg in chat_history:
if msg["role"] == "user":
messages.append(HumanMessage(content=msg["content"]))
elif msg["role"] == "assistant":
messages.append(AIMessage(content=msg["content"]))
# Add current message
messages.append(HumanMessage(content=message))
2025-12-16 13:55:16 +08:00
# Use the low-level graph directly
result = await self.react_agent.ainvoke({"messages": messages}, {"recursion_limit": 6}, )
2025-12-04 14:48:38 +08:00
# Extract final response
final_response = ""
if "messages" in result and result["messages"]:
last_message = result["messages"][-1]
if hasattr(last_message, 'content'):
final_response = last_message.content
elif isinstance(last_message, dict) and "content" in last_message:
final_response = last_message["content"]
return {
"response": final_response,
"intermediate_steps": [],
"success": True,
"error": None
}
except Exception as e:
logger.error(f"LangGraph chat error: {str(e)}", exc_info=True)
return {
"response": f"抱歉,处理您的请求时出现错误: {str(e)}",
"intermediate_steps": [],
"success": False,
"error": str(e)
}
async def chat_stream(self, message: str, chat_history: Optional[List[Dict[str, str]]] = None) -> AsyncGenerator[
Dict[str, Any], None]:
"""Process a chat message using LangGraph with streaming."""
try:
logger.info(f"Starting streaming chat with message: {message[:100]}...")
# Convert chat history to messages
messages = []
if chat_history:
for msg in chat_history:
if msg["role"] == "user":
messages.append(HumanMessage(content=msg["content"]))
elif msg["role"] == "assistant":
messages.append(AIMessage(content=msg["content"]))
# Add current message
messages.append(HumanMessage(content=message))
# Track state for streaming
intermediate_steps = []
final_response_started = False
accumulated_response = ""
final_ai_message = None
# Stream the agent execution
2025-12-16 13:55:16 +08:00
async for event in self.react_agent.astream({"messages": messages}):
2025-12-04 14:48:38 +08:00
# Handle different event types from LangGraph
print('event===', event)
if isinstance(event, dict):
for node_name, node_output in event.items():
logger.info(f"Processing node: {node_name}, output type: {type(node_output)}")
# 处理 tools 节点
if "tools" in node_name.lower():
# 提取工具信息
tool_infos = []
if isinstance(node_output, dict) and "messages" in node_output:
messages_in_output = node_output["messages"]
for msg in messages_in_output:
# 处理 ToolMessage 对象
if hasattr(msg, 'name') and hasattr(msg, 'content'):
tool_info = {
"tool_name": msg.name,
"tool_output": msg.content,
"tool_call_id": getattr(msg, 'tool_call_id', ''),
"status": "completed"
}
tool_infos.append(tool_info)
elif isinstance(msg, dict):
if 'name' in msg and 'content' in msg:
tool_info = {
"tool_name": msg['name'],
"tool_output": msg['content'],
"tool_call_id": msg.get('tool_call_id', ''),
"status": "completed"
}
tool_infos.append(tool_info)
# 返回 tools_end 事件
for tool_info in tool_infos:
yield {
"type": "tools_end",
"content": f"工具 {tool_info['tool_name']} 执行完成",
"tool_name": tool_info["tool_name"],
"tool_output": tool_info["tool_output"],
"node_name": node_name,
"done": False
}
await asyncio.sleep(0.1)
# 处理 agent 节点
elif "agent" in node_name.lower():
if isinstance(node_output, dict) and "messages" in node_output:
messages_in_output = node_output["messages"]
if messages_in_output:
last_msg = messages_in_output[-1]
# 获取 finish_reason
finish_reason = None
if hasattr(last_msg, 'response_metadata'):
finish_reason = last_msg.response_metadata.get('finish_reason')
elif isinstance(last_msg, dict) and 'response_metadata' in last_msg:
finish_reason = last_msg['response_metadata'].get('finish_reason')
# 判断是否为 thinking 或 response
if finish_reason == 'tool_calls':
# thinking 状态
thinking_content = "🤔 正在思考..."
if hasattr(last_msg, 'content') and last_msg.content:
thinking_content = f"🤔 思考: {last_msg.content[:200]}..."
elif isinstance(last_msg, dict) and "content" in last_msg:
thinking_content = f"🤔 思考: {last_msg['content'][:200]}..."
yield {
"type": "thinking",
"content": thinking_content,
"node_name": node_name,
"raw_output": str(node_output)[:500] if node_output else "",
"done": False
}
await asyncio.sleep(0.1)
elif finish_reason == 'stop':
# response 状态
if hasattr(last_msg, 'content') and hasattr(last_msg,
'__class__') and 'AI' in last_msg.__class__.__name__:
current_content = last_msg.content
final_ai_message = last_msg
if not final_response_started and current_content:
final_response_started = True
yield {
"type": "response_start",
"content": "",
"intermediate_steps": intermediate_steps,
"done": False
}
if current_content and len(current_content) > len(accumulated_response):
new_content = current_content[len(accumulated_response):]
for char in new_content:
accumulated_response += char
yield {
"type": "response",
"content": accumulated_response,
"intermediate_steps": intermediate_steps,
"done": False
}
await asyncio.sleep(0.03)
else:
# 其他 agent 状态
yield {
"type": "step",
"content": f"📋 执行步骤: {node_name}",
"node_name": node_name,
"raw_output": str(node_output)[:500] if node_output else "",
"done": False
}
await asyncio.sleep(0.1)
# 处理其他节点
else:
yield {
"type": "step",
"content": f"📋 执行步骤: {node_name}",
"node_name": node_name,
"raw_output": str(node_output)[:500] if node_output else "",
"done": False
}
await asyncio.sleep(0.1)
# 最终完成事件
yield {
"type": "complete",
"content": accumulated_response,
"intermediate_steps": intermediate_steps,
"done": True
}
except Exception as e:
logger.error(f"Error in chat_stream: {str(e)}", exc_info=True)
yield {
"type": "error",
"content": f"处理请求时出错: {str(e)}",
"done": True
}
# 确保最终响应包含完整内容
final_content = accumulated_response
if not final_content and final_ai_message and hasattr(final_ai_message, 'content'):
final_content = final_ai_message.content or ""
# Final completion signal
yield {
"type": "response",
"content": final_content,
"intermediate_steps": intermediate_steps,
"done": True
}
except Exception as e:
logger.error(f"LangGraph chat stream error: {str(e)}", exc_info=True)
yield {
"type": "error",
"content": f"抱歉,处理您的请求时出现错误: {str(e)}",
"error": str(e),
"done": True
}
def get_available_tools(self) -> List[Dict[str, Any]]:
"""Get list of available tools."""
tools = []
for tool in self.tools:
tools.append({
"name": tool.name,
"description": tool.description,
"parameters": [],
"enabled": True
})
return tools
def get_config(self) -> Dict[str, Any]:
"""Get current agent configuration."""
return self.config.dict()
def update_config(self, config: Dict[str, Any]):
"""Update agent configuration."""
for key, value in config.items():
if hasattr(self.config, key):
setattr(self.config, key, value)
# Recreate agent with new config
2025-12-16 13:55:16 +08:00
self._create_react_agent()
2025-12-04 14:48:38 +08:00
logger.info("Agent configuration updated")
2025-12-16 13:55:16 +08:00
def _create_plan_execute_agent(self):
"""Create a Plan-and-Execute agent using LangGraph low-level API.
结构START -> planner -> executor(loop) -> summarize -> END
- planner根据用户问题生成计划JSON 数组
- executor逐步执行计划可调用工具收集每步结果
- summarize综合计划与执行结果产出最终回答
"""
from typing import TypedDict, Annotated, List
import json
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage, BaseMessage
try:
self.bound_model = self.model.bind_tools(self.tools)
except Exception as e:
logger.warning(f"Failed to bind tools to model, tool calling may not work: {e}")
self.bound_model = self.model
class PlanState(TypedDict):
messages: Annotated[List[BaseMessage], add_messages]
plan_steps: List[str]
current_step: int
step_results: List[str]
def planner_node(state: PlanState) -> PlanState:
messages = state.get("messages", [])
plan_prompt = (
"你是规划助手。基于对话内容生成可执行计划,"
"用 JSON 数组返回,每个元素是一条明确且可操作的步骤。"
"仅输出 JSON不要额外解释。"
)
ai_plan = self.model.invoke(messages + [HumanMessage(content=plan_prompt)])
steps: List[str] = []
try:
parsed = json.loads(ai_plan.content)
if isinstance(parsed, list):
steps = [str(s) for s in parsed]
except Exception:
# 回退:按行拆分
steps = [s.strip() for s in ai_plan.content.split("\n") if s.strip()]
return {
"messages": [ai_plan],
"plan_steps": steps,
"current_step": 0,
"step_results": []
}
def executor_node(state: PlanState) -> PlanState:
idx = state.get("current_step", 0)
steps = state.get("plan_steps", [])
msgs = state.get("messages", [])
if idx >= len(steps):
return {"messages": [], "current_step": idx, "step_results": state.get("step_results", [])}
step_text = steps[idx]
exec_prompt = (
f"请执行计划的第{idx+1}步:{step_text}"
"需要用工具时创建工具调用;完成后给出该步的结果。"
)
ai_exec = self.bound_model.invoke(msgs + [HumanMessage(content=exec_prompt)])
new_messages: List[BaseMessage] = [ai_exec]
step_result_content = None
# 处理工具调用
tool_map = {t.name: t for t in self.tools}
tool_msgs: List[ToolMessage] = []
tool_calls = getattr(ai_exec, "tool_calls", []) or (ai_exec.additional_kwargs.get("tool_calls") if hasattr(ai_exec, "additional_kwargs") else [])
if tool_calls:
for call in tool_calls:
name = call.get("name")
args = call.get("args", {})
tool_obj = tool_map.get(name)
if tool_obj:
try:
result = tool_obj.invoke(args)
except Exception as e:
result = f"工具执行失败: {e}"
else:
result = f"未找到工具: {name}"
tool_call_id = call.get("id") or call.get("tool_call_id") or call.get("call_id") or f"tool_{name}"
tool_msgs.append(ToolMessage(content=str(result), tool_call_id=tool_call_id, name=name or "tool"))
new_messages.extend(tool_msgs)
# 基于工具输出总结该步结果
summarize_step = "请基于上述工具输出,总结该步骤的结果,给出结构化要点与可读说明。"
ai_step = self.bound_model.invoke(msgs + [ai_exec] + tool_msgs + [HumanMessage(content=summarize_step)])
step_result_content = ai_step.content
new_messages.append(ai_step)
else:
step_result_content = ai_exec.content
all_results = list(state.get("step_results", []))
if step_result_content:
all_results.append(step_result_content)
return {
"messages": new_messages,
"current_step": idx + 1,
"step_results": all_results
}
def route_after_planner(state: PlanState) -> str:
return "executor" if state.get("plan_steps") else END
def route_after_executor(state: PlanState) -> str:
cur = state.get("current_step", 0)
total = len(state.get("plan_steps", []))
return "executor" if cur < total else "summarize"
def summarize_node(state: PlanState) -> PlanState:
import json as _json
msgs = state.get("messages", [])
steps = state.get("plan_steps", [])
results = state.get("step_results", [])
final_prompt = (
"请综合以上计划与各步骤结果,生成最终回答。"
"要求:逻辑清晰、结论明确、可读性强;如存在不确定性请注明。"
)
context_msg = HumanMessage(content=(
f"计划: {_json.dumps(steps, ensure_ascii=False)}\n"
f"步骤结果: {_json.dumps(results, ensure_ascii=False)}\n"
f"{final_prompt}"
))
ai_final = self.model.invoke(msgs + [context_msg])
return {"messages": [ai_final]}
graph = StateGraph(PlanState)
graph.add_node("planner", planner_node)
graph.add_node("executor", executor_node)
graph.add_node("summarize", summarize_node)
graph.add_edge(START, "planner")
graph.add_conditional_edges("planner", route_after_planner, {"executor": "executor", END: END})
graph.add_conditional_edges("executor", route_after_executor, {"executor": "executor", "summarize": "summarize"})
graph.add_edge("summarize", END)
self.plan_execute_agent = graph.compile()
async def chat_plan_execute(self, message: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
"""Single-turn Plan-and-Execute chat."""
# 确保 agent 已创建
if not hasattr(self, "plan_execute_agent"):
self._create_plan_execute_agent()
# 构建消息
messages = []
if chat_history:
for msg in chat_history:
role = msg.get("role")
content = msg.get("content", "")
if role == "user":
messages.append(HumanMessage(content=content))
else:
messages.append(AIMessage(content=content))
messages.append(HumanMessage(content=message))
try:
result = await self.plan_execute_agent.ainvoke({"messages": messages}, config={"recursion_limit": self.config.max_iterations})
final_msg = None
if isinstance(result, dict) and "messages" in result:
ms = result["messages"]
if ms:
final_msg = ms[-1]
final_text = getattr(final_msg, "content", "") if final_msg else ""
return {
"status": "success",
"response": final_text,
"raw": str(result)
}
except Exception as e:
logger.error(f"Error in chat_plan_execute: {e}", exc_info=True)
return {
"status": "error",
"error": str(e)
}
async def chat_stream_plan_execute(self, message: str, chat_history: Optional[List[Dict[str, str]]] = None) -> AsyncGenerator[Dict[str, Any], None]:
"""Streamed Plan-and-Execute chat."""
import asyncio as _asyncio
if not hasattr(self, "plan_execute_agent"):
self._create_plan_execute_agent()
messages = []
if chat_history:
for msg in chat_history:
role = msg.get("role")
content = msg.get("content", "")
if role == "user":
messages.append(HumanMessage(content=content))
else:
messages.append(AIMessage(content=content))
messages.append(HumanMessage(content=message))
try:
accumulated = ""
async for event in self.react_agent.astream({"messages": messages}, config={"recursion_limit": self.config.max_iterations}):
for key, node_output in event.items():
node_name = key[0] if isinstance(key, tuple) else key
if node_name == "planner":
# 规划阶段
content = "生成计划中..."
if node_output and isinstance(node_output, dict):
m = node_output.get("messages", [])
if m:
last = m[-1]
if hasattr(last, "content"):
content = str(last.content)[:400]
yield {"type": "planning", "content": content, "done": False}
await _asyncio.sleep(0.05)
elif node_name == "executor":
# 执行阶段(可能包含工具)
yield {"type": "step", "content": "执行计划步骤", "done": False}
await _asyncio.sleep(0.05)
if node_output and isinstance(node_output, dict):
msgs = node_output.get("messages", [])
# 输出工具结束标记
tool_msgs = [m for m in msgs if hasattr(m, "__class__") and "Tool" in m.__class__.__name__]
if tool_msgs:
yield {"type": "tools_end", "content": f"完成 {len(tool_msgs)} 次工具执行", "done": False}
await _asyncio.sleep(0.03)
# 尝试输出该步总结
ai_msgs = [m for m in msgs if hasattr(m, "__class__") and "AI" in m.__class__.__name__]
if ai_msgs:
text = ai_msgs[-1].content
if text:
accumulated = text
yield {"type": "response", "content": accumulated, "done": False}
await _asyncio.sleep(0.02)
elif node_name == "summarize":
# 最终总结
if node_output and isinstance(node_output, dict):
msgs = node_output.get("messages", [])
if msgs:
final = msgs[-1]
content = getattr(final, "content", "")
if content:
yield {"type": "response_start", "content": "", "done": False}
yield {"type": "response", "content": content, "done": False}
accumulated = content
await _asyncio.sleep(0.02)
yield {"type": "complete", "content": accumulated, "done": True}
except Exception as e:
logger.error(f"Error in chat_stream_plan_execute: {e}", exc_info=True)
yield {"type": "error", "content": str(e), "done": True}
2025-12-04 14:48:38 +08:00
# Global instance
_langgraph_agent_service: Optional[LangGraphAgentService] = None
def get_langgraph_agent_service(db_session=None) -> LangGraphAgentService:
"""Get or create LangGraph agent service instance."""
global _langgraph_agent_service
if _langgraph_agent_service is None:
_langgraph_agent_service = LangGraphAgentService(db_session)
logger.info("LangGraph Agent service initialized")
return _langgraph_agent_service