主要变化: - 新增 agent_v2.py: 用 LangGraph create_react_agent + astream_events 替代原 agent_chat_test 的 LLM step-routing 死循环 - 新增 tools_v2.py: 闭包工厂模式,每个请求按 uuid 生成工具列表, 消除 toolinput 字符串拼 JSON 注入 uuid 的旧 hack - chat_test.py:266-346: 删 11 次 count_process 重试外层和事件 分发 spaghetti,换成 agent_run 单次调用 + 简单事件 dispatcher - policy_fun_iast.py:168-187: 修 broken <think> filter 老代码把 start_flag 设反了(看见 <think> 才开始 yield)导致 非 think 模型 yield 不出任何内容;改为正确跳过 <think>...</think> 块 模型函数调用通过 langchain_openai.ChatOpenAI(不能用旧版 langchain_community.chat_models.ChatOpenAI,没有现代 tool calling)。 依赖: langgraph==0.0.49 + langchain-core==0.1.53(已在服务器装好)。 非 stream 分支保留旧 agent_chat_test 路径(极少触发,回归风险低)。 旧版回滚: git checkout backup/pre-langgraph 实测对比: - 旧版 30-60s,答案 0 字(filter 卡死后展示 11 次重试) - 新版 25-40s,答案完整(含工具调用、参考文献、推荐问题、摘要) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
6.9 KiB
Python
167 lines
6.9 KiB
Python
"""
|
||
LangGraph 版 Agent runner。
|
||
|
||
替代旧的 agent_chat_test 内核:
|
||
- 不再用 LLM 做 step routing(thinking/select_tool/answer),让模型 function-calling 自己决定
|
||
- 同一轮的多个 tool_calls 自动并行(ToolNode)
|
||
- 把 LangGraph 事件流映射到现有前端协议({"text":...}/{"docs":...}/{"detail":...})
|
||
|
||
输入:query + history + uuid + model_name
|
||
输出:和旧版 agent_chat_test 一样的 dict 序列("answer"/"docs"/"detail"/...)
|
||
"""
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from typing import AsyncIterable, List, Optional
|
||
|
||
from langgraph.prebuilt import create_react_agent
|
||
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
|
||
from langchain_openai import ChatOpenAI
|
||
|
||
from configs import LLM_MODELS, prompt_config
|
||
from server.utils import get_prompt_template, get_model_worker_config
|
||
from server.chat import utils as shared_utils
|
||
from server.chat.tools_v2 import make_tools
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _build_system_prompt(user_prompt_name: str, query: str, think_content: str) -> str:
|
||
"""复用旧版 Think Test Bak + 用户业务 prompt 的拼装逻辑,但简化为单条 system message。"""
|
||
base = get_prompt_template("agent_chat", "Think Test Bak")
|
||
user = get_prompt_template("llm_chat", user_prompt_name) if user_prompt_name else ""
|
||
parts = []
|
||
parts.append("你是浪潮开发的智能专家。回答用户问题前可以使用工具检索资料。")
|
||
parts.append("严格要求:")
|
||
parts.append("1. 优先使用工具获取资料后再回答,禁止虚构内容")
|
||
parts.append("2. 同一个工具同一参数禁止反复调用超过 2 次")
|
||
parts.append("3. 回答时必须基于工具返回的资料,引用要标注【】序号")
|
||
parts.append("4. 涉及国家政策优先用 知识库联想 + 政策库")
|
||
parts.append("5. 答案紧扣用户问题,不要主观臆想")
|
||
parts.append("")
|
||
parts.append(f"思考提示:{think_content}")
|
||
parts.append("")
|
||
if user:
|
||
parts.append(f"业务约束:{user}")
|
||
return "\n".join(parts)
|
||
|
||
|
||
def _convert_history(history: list) -> list:
|
||
"""把 chat_test.py 的 history list(dict role/content)转成 LangChain messages。"""
|
||
msgs = []
|
||
for h in history or []:
|
||
role = h.get("role")
|
||
content = h.get("content", "")
|
||
if role == "user":
|
||
msgs.append(("user", content))
|
||
elif role == "assistant":
|
||
msgs.append(("assistant", content))
|
||
return msgs
|
||
|
||
|
||
async def agent_run(
|
||
*,
|
||
query: str,
|
||
uuid: str,
|
||
history: Optional[list] = None,
|
||
model_name: str = None,
|
||
temperature: float = 0.3,
|
||
max_tokens: Optional[int] = None,
|
||
user_prompt_name: str = "",
|
||
think_content: str = "",
|
||
) -> AsyncIterable[str]:
|
||
"""运行 LangGraph agent,yield 事件 JSON 字符串。
|
||
|
||
yield 协议(向后兼容 chat_test.py 的消费逻辑):
|
||
{"text": str} → 思考框/答案框文本(按出现位置区分)
|
||
{"answer": str} → token 级答案流(chat_test 包装为 {"text":...})
|
||
{"docs": str} → 工具返回的资料文档(参考文献区)
|
||
{"detail": str} → 详细资料累积(detail_answer 用)
|
||
{"tool_start": dict} → 调试/日志:工具开始
|
||
{"tool_end": dict} → 调试/日志:工具结束
|
||
"""
|
||
model_name = model_name or LLM_MODELS[0]
|
||
# 必须用 langchain_openai.ChatOpenAI(支持现代 tool calling 协议)
|
||
# 不能用 server.utils.get_ChatOpenAI(返回 langchain_community 老版,不支持 bind_tools)
|
||
cfg = get_model_worker_config(model_name)
|
||
llm = ChatOpenAI(
|
||
model=model_name,
|
||
base_url=cfg.get("api_base_url"),
|
||
api_key=cfg.get("api_key", "EMPTY"),
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
streaming=True,
|
||
)
|
||
|
||
tools = make_tools(uuid)
|
||
|
||
# 用 Think Test Bak + user_prompt 构造 system message
|
||
system_prompt = _build_system_prompt(user_prompt_name, query, think_content)
|
||
agent = create_react_agent(llm, tools=tools, messages_modifier=system_prompt)
|
||
|
||
msgs = _convert_history(history)
|
||
msgs.append(("user", query))
|
||
inputs = {"messages": msgs}
|
||
config = {"recursion_limit": 12} # 最多 12 步(远小于旧版 11 次外层 × N 内层)
|
||
|
||
answer_buf = []
|
||
try:
|
||
async for ev in agent.astream_events(inputs, config=config, version="v1"):
|
||
# 检查停止信号
|
||
if not shared_utils.get_shared_variable(uuid).get("status", True):
|
||
logger.info("Agent 收到停止信号")
|
||
break
|
||
|
||
kind = ev["event"]
|
||
name = ev.get("name", "")
|
||
|
||
if kind == "on_chat_model_stream":
|
||
chunk = ev["data"]["chunk"]
|
||
content = chunk.content or ""
|
||
if content:
|
||
answer_buf.append(content)
|
||
yield json.dumps({"answer": content}, ensure_ascii=False)
|
||
|
||
elif kind == "on_tool_start":
|
||
tool_input = ev["data"].get("input", {})
|
||
logger.info(f"工具调用开始: {name}({tool_input})")
|
||
# 工具说明落到思考框(前端的 thinking 区域)
|
||
yield json.dumps(
|
||
{"think": f"\n→ 调用工具:{name}\n"},
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
elif kind == "on_tool_end":
|
||
output = str(ev["data"].get("output", ""))
|
||
logger.info(f"工具调用结束: {name} → {len(output)} chars")
|
||
|
||
# 知识库联想 / 联网思索 → 提取 source_docs 给前端参考文献区
|
||
if name in ("知识库联想", "联网思索"):
|
||
source = shared_utils.get_shared_variable(uuid)
|
||
source_docs = source.get("source_docs", [])
|
||
if source_docs:
|
||
try:
|
||
docs_string = "\n" + "\n".join(f"{str(d)}\n" for d in source_docs)
|
||
yield json.dumps({"docs": docs_string}, ensure_ascii=False)
|
||
except Exception:
|
||
logger.exception("docs 序列化失败")
|
||
|
||
# detail(详细搜索内容)累积到 docs_detail,给后续幻觉校验用
|
||
if name in ("知识库联想", "联网思索"):
|
||
yield json.dumps({"detail": output}, ensure_ascii=False)
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info("Agent 被取消")
|
||
raise
|
||
except Exception as e:
|
||
logger.exception(f"Agent 运行异常: {e}")
|
||
# 给前端一个兜底答案
|
||
yield json.dumps(
|
||
{"answer": f"\n\n[Agent 运行异常] 已尽力使用工具但未能完整生成答案,请重试或简化问题。"},
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
# 终态收尾
|
||
full_answer = "".join(answer_buf)
|
||
logger.info(f"Agent 完成:答案长度 {len(full_answer)} chars")
|