feat:优化节点错误处理
This commit is contained in:
@@ -17,8 +17,6 @@ class BizException(AppError):
|
|||||||
"""可安全返回给调用方的业务错误"""
|
"""可安全返回给调用方的业务错误"""
|
||||||
|
|
||||||
def __init__(self, code: int, msg: str = "请求失败") -> None:
|
def __init__(self, code: int, msg: str = "请求失败") -> None:
|
||||||
if not 400 <= code <= 599:
|
|
||||||
raise ValueError("业务错误码必须是 400 到 599 的 HTTP 状态码")
|
|
||||||
self.code = code
|
self.code = code
|
||||||
self.msg = msg
|
self.msg = msg
|
||||||
super().__init__(msg)
|
super().__init__(msg)
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class BaseNode(ABC):
|
|||||||
def process(self, state: T) -> T:
|
def process(self, state: T) -> T:
|
||||||
"""子类实现节点核心逻辑,返回状态更新"""
|
"""子类实现节点核心逻辑,返回状态更新"""
|
||||||
|
|
||||||
|
def fail_on_invalid_output(self, state: T, error: ValueError, reason: str) -> T:
|
||||||
|
"""模型输出未通过校验时收尾为失败结果,详细原因只写日志"""
|
||||||
|
self.logger.warning(f"{reason}: {error}")
|
||||||
|
return {**state, "status": "failed", "failure_reason": reason}
|
||||||
|
|
||||||
def log_step(self, step_name: str, message: str = ""):
|
def log_step(self, step_name: str, message: str = ""):
|
||||||
"""记录步骤日志。"""
|
"""记录步骤日志。"""
|
||||||
log_msg = f"[{step_name}]"
|
log_msg = f"[{step_name}]"
|
||||||
|
|||||||
+23
-5
@@ -28,9 +28,11 @@ def route_after_intent(state: QueryState) -> str:
|
|||||||
return state["intent"]
|
return state["intent"]
|
||||||
|
|
||||||
|
|
||||||
def route_after_requirement(state: QueryState) -> bool:
|
def route_after_requirement(state: QueryState) -> str:
|
||||||
# True 进入澄清,False 进入修改范围判断
|
# 需求输出校验失败直接收尾;需要澄清进入澄清,否则进入修改范围判断
|
||||||
return state["requirement"].needs_clarification
|
if state["status"] == "failed":
|
||||||
|
return "fallback_node"
|
||||||
|
return "clarify_node" if state["requirement"].needs_clarification else "scope_node"
|
||||||
|
|
||||||
|
|
||||||
def route_after_wait(state: QueryState) -> str:
|
def route_after_wait(state: QueryState) -> str:
|
||||||
@@ -38,7 +40,15 @@ def route_after_wait(state: QueryState) -> str:
|
|||||||
return "input_node" if state["status"] == "running" else END
|
return "input_node" if state["status"] == "running" else END
|
||||||
|
|
||||||
|
|
||||||
|
# TODO 目前“资源”只有物料目录,数据源和 RAG 尚未准备好
|
||||||
|
# 接入后把 needs_planning 与 resource_needs 拆成两个独立开关:
|
||||||
|
# 规划 -> 按需查资源 -> planner;无需规划但有 resource_needs -> 查资源 -> action
|
||||||
|
# 并让 component_resource_node 按 resource_needs 决定查什么,而不是固定全量目录
|
||||||
|
# 在此之前,无需规划且带 resource_needs 的请求会由 action_block_reason 判为失败
|
||||||
def route_after_scope(state: QueryState) -> str:
|
def route_after_scope(state: QueryState) -> str:
|
||||||
|
# 范围输出校验失败或已判定无法执行,直接失败收尾
|
||||||
|
if state["status"] == "failed":
|
||||||
|
return "fallback_node"
|
||||||
# 需要规划时先获取组件能力,供规划节点选择可用组件
|
# 需要规划时先获取组件能力,供规划节点选择可用组件
|
||||||
if state["change_scope"].needs_planning:
|
if state["change_scope"].needs_planning:
|
||||||
return "component_resource_node"
|
return "component_resource_node"
|
||||||
@@ -132,7 +142,11 @@ def build_graph(checkpointer=None):
|
|||||||
workflow.add_conditional_edges(
|
workflow.add_conditional_edges(
|
||||||
source="requirement_node",
|
source="requirement_node",
|
||||||
path=route_after_requirement,
|
path=route_after_requirement,
|
||||||
path_map={True: "clarify_node", False: "scope_node"},
|
path_map={
|
||||||
|
"clarify_node": "clarify_node",
|
||||||
|
"scope_node": "scope_node",
|
||||||
|
"fallback_node": "fallback_node",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 先保存澄清问题和等待状态,再进入调用 interrupt 的节点暂停执行
|
# 先保存澄清问题和等待状态,再进入调用 interrupt 的节点暂停执行
|
||||||
@@ -160,7 +174,11 @@ def build_graph(checkpointer=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 规划完成后交给动作节点转换,动作节点还会检查规划是否可执行
|
# 规划完成后交给动作节点转换,动作节点还会检查规划是否可执行
|
||||||
workflow.add_edge("planner_node", "action_node")
|
workflow.add_conditional_edges(
|
||||||
|
"planner_node",
|
||||||
|
lambda state: "fallback_node" if state["status"] == "failed" else "action_node",
|
||||||
|
{"action_node": "action_node", "fallback_node": "fallback_node"},
|
||||||
|
)
|
||||||
|
|
||||||
# 动作生成后按结果进入校验、修正分析或失败收尾
|
# 动作生成后按结果进入校验、修正分析或失败收尾
|
||||||
workflow.add_conditional_edges(
|
workflow.add_conditional_edges(
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ from app.processor.state import QueryState
|
|||||||
from app.prompt.planner import PLANNER_SYSTEM_PROMPT
|
from app.prompt.planner import PLANNER_SYSTEM_PROMPT
|
||||||
from app.schemas.screen_plan import ScreenPlan
|
from app.schemas.screen_plan import ScreenPlan
|
||||||
|
|
||||||
|
# 规划输出未通过校验时,带着错误让模型重新规划的最大次数
|
||||||
|
# TODO 上游节点目前只有这里做了节点内重试,scope_node 和 requirement_node 尚未跟进
|
||||||
|
# TODO 将来可改为图级回路:规划校验失败 -> 规划版 reflection -> planner,
|
||||||
|
# 与动作阶段的重试统一由 route_failure 控制,届时可去掉这里的节点内重试
|
||||||
|
PLAN_MAX_REPAIRS = 1
|
||||||
|
|
||||||
|
|
||||||
class PlannerNode(BaseNode):
|
class PlannerNode(BaseNode):
|
||||||
name = "planner_node"
|
name = "planner_node"
|
||||||
@@ -26,28 +32,64 @@ class PlannerNode(BaseNode):
|
|||||||
# 图保证组件目录已就绪后才进入规划
|
# 图保证组件目录已就绪后才进入规划
|
||||||
resources = state["component_resources"]
|
resources = state["component_resources"]
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
# 系统消息提供稳定的规划规则和输出边界
|
||||||
|
SystemMessage(content=PLANNER_SYSTEM_PROMPT),
|
||||||
|
# 用户消息承载本次上下文、需求、范围及组件能力摘要
|
||||||
|
HumanMessage(
|
||||||
|
content=json.dumps(
|
||||||
|
{
|
||||||
|
"input_context": context.model_dump(mode="json"),
|
||||||
|
"requirement": requirement.model_dump(mode="json"),
|
||||||
|
"change_scope": scope.model_dump(mode="json"),
|
||||||
|
# 仅提供规划需要的组件摘要,省略完整默认配置
|
||||||
|
"component_resources": resources.to_planner_context(),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
llm_client = get_llm(ScreenPlan)
|
llm_client = get_llm(ScreenPlan)
|
||||||
result = await llm_client.ainvoke(
|
|
||||||
[
|
|
||||||
# 系统消息提供稳定的规划规则和输出边界
|
|
||||||
SystemMessage(content=PLANNER_SYSTEM_PROMPT),
|
|
||||||
# 用户消息承载本次上下文、需求、范围及组件能力摘要
|
|
||||||
HumanMessage(
|
|
||||||
content=json.dumps(
|
|
||||||
{
|
|
||||||
"input_context": context.model_dump(mode="json"),
|
|
||||||
"requirement": requirement.model_dump(mode="json"),
|
|
||||||
"change_scope": scope.model_dump(mode="json"),
|
|
||||||
# 仅提供规划需要的组件摘要,省略完整默认配置
|
|
||||||
"component_resources": resources.to_planner_context(),
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
plan = result
|
for attempt in range(PLAN_MAX_REPAIRS + 1):
|
||||||
|
plan = None
|
||||||
|
try:
|
||||||
|
plan = await llm_client.ainvoke(messages)
|
||||||
|
self._check(plan, state)
|
||||||
|
break
|
||||||
|
except ValueError as error:
|
||||||
|
# 解析失败、引用不存在的目标等可由模型自行纠正,超过次数才失败收尾
|
||||||
|
if attempt == PLAN_MAX_REPAIRS:
|
||||||
|
return self.fail_on_invalid_output(
|
||||||
|
state, error, "未能生成可用的大屏规划,请调整需求后重试"
|
||||||
|
)
|
||||||
|
self.logger.warning(f"规划未通过校验,重新规划: {error}")
|
||||||
|
feedback = f"上一次输出未通过校验:{error}\n请修正后重新输出完整规划。"
|
||||||
|
if plan is not None:
|
||||||
|
feedback = f"上一次输出:\n{plan.model_dump_json()}\n\n{feedback}"
|
||||||
|
messages = [*messages, HumanMessage(content=feedback)]
|
||||||
|
|
||||||
|
# 合并需求约束和规划约束,按首次出现顺序去重,保留需求约束在前
|
||||||
|
plan.constraints = list(
|
||||||
|
dict.fromkeys([*requirement.constraints, *plan.constraints])
|
||||||
|
)
|
||||||
|
# 同样合并原始假设与规划补充的假设,避免重复记录或丢失前置条件
|
||||||
|
plan.assumptions = list(
|
||||||
|
dict.fromkeys([*requirement.assumptions, *plan.assumptions])
|
||||||
|
)
|
||||||
|
# 保存通过校验的规划,供后续动作生成使用
|
||||||
|
updated_state["screen_plan"] = plan
|
||||||
|
|
||||||
|
# 返回更新后的状态,由图继续处理规划对应的动作
|
||||||
|
return updated_state
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check(plan: ScreenPlan, state: QueryState) -> None:
|
||||||
|
"""校验规划的组件类型、目标引用和修改范围,不通过时抛出 ValueError"""
|
||||||
|
requirement = state["requirement"]
|
||||||
|
scope = state["change_scope"]
|
||||||
|
context = state["input_context"]
|
||||||
|
resources = state["component_resources"]
|
||||||
|
|
||||||
# 校验规划中的物料类型是否真实受支持
|
# 校验规划中的物料类型是否真实受支持
|
||||||
supported_types = {component.type for component in resources.components}
|
supported_types = {component.type for component in resources.components}
|
||||||
@@ -97,20 +139,6 @@ class PlannerNode(BaseNode):
|
|||||||
if outside := changed_ids - set(scope.target_ids):
|
if outside := changed_ids - set(scope.target_ids):
|
||||||
raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}")
|
raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}")
|
||||||
|
|
||||||
# 合并需求约束和规划约束,按首次出现顺序去重,保留需求约束在前
|
|
||||||
plan.constraints = list(
|
|
||||||
dict.fromkeys([*requirement.constraints, *plan.constraints])
|
|
||||||
)
|
|
||||||
# 同样合并原始假设与规划补充的假设,避免重复记录或丢失前置条件
|
|
||||||
plan.assumptions = list(
|
|
||||||
dict.fromkeys([*requirement.assumptions, *plan.assumptions])
|
|
||||||
)
|
|
||||||
# 保存通过校验的规划,供后续动作生成使用
|
|
||||||
updated_state["screen_plan"] = plan
|
|
||||||
|
|
||||||
# 返回更新后的状态,由图继续处理规划对应的动作
|
|
||||||
return updated_state
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ class RequirementNode(BaseNode):
|
|||||||
name = "requirement_node"
|
name = "requirement_node"
|
||||||
|
|
||||||
async def process(self, state: QueryState) -> QueryState:
|
async def process(self, state: QueryState) -> QueryState:
|
||||||
|
try:
|
||||||
|
return await self._generate(state)
|
||||||
|
except ValueError as error:
|
||||||
|
# 结构化输出解析失败或引用校验不通过(含 ValidationError、OutputParserException)
|
||||||
|
return self.fail_on_invalid_output(
|
||||||
|
state, error, "未能可靠理解你的需求,请换个说法再试"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate(self, state: QueryState) -> QueryState:
|
||||||
# 浅拷贝状态
|
# 浅拷贝状态
|
||||||
updated_state: QueryState = state.copy()
|
updated_state: QueryState = state.copy()
|
||||||
# 获取输入节点已校验的上下文,包含用户需求、当前大屏和澄清记录
|
# 获取输入节点已校验的上下文,包含用户需求、当前大屏和澄清记录
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ class ScopeNode(BaseNode):
|
|||||||
name = "scope_node"
|
name = "scope_node"
|
||||||
|
|
||||||
async def process(self, state: QueryState) -> QueryState:
|
async def process(self, state: QueryState) -> QueryState:
|
||||||
|
try:
|
||||||
|
return await self._generate(state)
|
||||||
|
except ValueError as error:
|
||||||
|
# 结构化输出解析失败或引用校验不通过(含 ValidationError、OutputParserException)
|
||||||
|
return self.fail_on_invalid_output(
|
||||||
|
state, error, "未能确定需要修改的范围,请描述得更具体一些"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate(self, state: QueryState) -> QueryState:
|
||||||
# 浅拷贝状态
|
# 浅拷贝状态
|
||||||
updated_state: QueryState = state.copy()
|
updated_state: QueryState = state.copy()
|
||||||
# 获取需求节点提取的结构化需求,作为范围判断的依据
|
# 获取需求节点提取的结构化需求,作为范围判断的依据
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""通过 LangGraph custom 流发送进度,不额外保存任务状态"""
|
"""通过 LangGraph custom 流发送进度,不额外保存任务状态"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Mapping
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -39,10 +38,8 @@ def node_progress(node: str, state):
|
|||||||
|
|
||||||
# 获取当前图的 custom 流写入器,供本次节点执行推送进度事件
|
# 获取当前图的 custom 流写入器,供本次节点执行推送进度事件
|
||||||
writer = get_stream_writer()
|
writer = get_stream_writer()
|
||||||
# 状态支持键值读取时用于提取标识,其他类型按空字典处理,避免调用 get 出错
|
|
||||||
values = state if isinstance(state, Mapping) else {}
|
|
||||||
# 以实际检查点配置为准,兼容没有在 state 中填写 ID 的本地示例
|
# 以实际检查点配置为准,兼容没有在 state 中填写 ID 的本地示例
|
||||||
thread_id = config.get("configurable", {}).get("thread_id") or values.get(
|
thread_id = config.get("configurable", {}).get("thread_id") or state.get(
|
||||||
"thread_id"
|
"thread_id"
|
||||||
)
|
)
|
||||||
# 为本次节点调用生成唯一编号,区分同一节点的首次执行、重试和恢复执行
|
# 为本次节点调用生成唯一编号,区分同一节点的首次执行、重试和恢复执行
|
||||||
@@ -54,7 +51,7 @@ def node_progress(node: str, state):
|
|||||||
# 将任务、用户消息和本次执行编号放入同一事件,便于消费方关联进度
|
# 将任务、用户消息和本次执行编号放入同一事件,便于消费方关联进度
|
||||||
event = NodeProgress(
|
event = NodeProgress(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
message_id=values.get("message_id"),
|
message_id=state.get("message_id"),
|
||||||
execution_id=execution_id,
|
execution_id=execution_id,
|
||||||
node=node,
|
node=node,
|
||||||
# 未配置用户说明时返回 None,序列化时省略该字段
|
# 未配置用户说明时返回 None,序列化时省略该字段
|
||||||
|
|||||||
@@ -101,16 +101,15 @@ class AgentService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _result(state, thread_id):
|
def _result(state, thread_id):
|
||||||
waiting = bool(state.get("__interrupt__"))
|
waiting = bool(state.get("__interrupt__"))
|
||||||
|
|
||||||
# 只交付校验通过的完整动作批次,失败草稿留在后端
|
# 只交付校验通过的完整动作批次,失败草稿留在后端
|
||||||
|
deliver = state["status"] == "completed" and state.get("validation_passed")
|
||||||
|
|
||||||
return AgentResult(
|
return AgentResult(
|
||||||
conversation_id=state["conversation_id"],
|
conversation_id=state["conversation_id"],
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
message_id=state["message_id"],
|
message_id=state["message_id"],
|
||||||
status="waiting_for_input" if waiting else state["status"],
|
status="waiting_for_input" if waiting else state["status"],
|
||||||
actions=state.get("actions") or []
|
actions=state["actions"] if deliver else [],
|
||||||
if state.get("validation_passed") is True and state["status"] == "completed"
|
|
||||||
else [],
|
|
||||||
answer=state.get("answer"),
|
answer=state.get("answer"),
|
||||||
clarification=state.get("clarification") if waiting else None,
|
clarification=state.get("clarification") if waiting else None,
|
||||||
failure_reason=state.get("failure_reason"),
|
failure_reason=state.get("failure_reason"),
|
||||||
|
|||||||
Reference in New Issue
Block a user