From d139a63e64a9d74ed7bf9db5c4121fcc77747bff Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Fri, 26 Jun 2026 10:19:04 +0800 Subject: [PATCH] =?UTF-8?q?refactor(server):=20=E6=84=8F=E5=9B=BE=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E6=94=B9=20LLM=20=E9=A9=B1=E5=8A=A8,=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=8F=AA=E5=81=9A=E9=97=B2=E8=81=8A=E6=8B=A6=E6=88=AA?= =?UTF-8?q?+resume=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规则不再判断'这是哪个业务场景'——那交给 LLM function call。 规则只保留两个不可替代职责:闲聊拦截(省 LLM 成本)、resume 确定性兜底。 - gate_classify 简化:删掉规则匹配门(94 词 CHOICE 匹配)和 ambiguous 提前判断 - 新增 _is_lightweight_off_topic:只拦 greeting+meaningless,不依赖业务关键词 - HANDLER_ONLY 改为 LLM 输出驱动:LLM 返回 query_travel_standard 后转 handler - 图拓扑简化:gate_classify 只输出 off_topic|resume|model_intent - 验证:76 passed;复合场景'出差并且报销招待费'→LLM 返回 2 task --- document/work-log/2026-06-25.md | 9 +- .../src/app/services/steward_graph_planner.py | 115 ++++++++++++------ 2 files changed, 84 insertions(+), 40 deletions(-) diff --git a/document/work-log/2026-06-25.md b/document/work-log/2026-06-25.md index 51c62ff..ff575e1 100644 --- a/document/work-log/2026-06-25.md +++ b/document/work-log/2026-06-25.md @@ -93,7 +93,14 @@ - 验证:后端全量 **76 passed**;端到端 4 场景全部通过——①出差申请(llm_function_call, 1 task)、②再提交(context_resume, 1 task, 5 fields)、③查差旅标准(scene_handler:query_travel_standard)、④闲聊(off_topic)。 - 影响:LangGraph 图成为唯一编排者,门控收敛到 gate_classify 单一决策点。endpoint 仍保留 hydrate/persist 两个补丁,P3 会收敛。 -## TODO +- 02:30:我把意图识别从"规则驱动"改为"LLM 驱动 + 极轻量规则过滤",规则不再判断"这是哪个业务场景"。 + - Git 提交检查:本地与 origin/main 同步。 + - 背景:gate_classify 用 94 关键词 + scene_registry CHOICE 规则匹配判断业务相关性,每加场景要维护关键词,关键词冲突(出差既可能申请也可能查标准),还导致 off_topic 误杀("下周去上海"被拦)。但 LLM 已经完全具备多场景识别能力(schema enum + prompt_fragment 都从 registry 动态生成),规则匹配门是多余的。 + - 修改①(简化 gate_classify):删掉规则匹配门(③)和 ambiguous 提前判断(④)。gate_classify 现在只做两件事:①闲聊拦截(极轻量:greeting+meaningless,省 LLM 成本)②resume 确定性兜底。其他全部默认走 model_intent。新增 `_is_lightweight_off_topic` 只调 `_looks_like_greeting`+`_looks_like_meaningless`,不调带 94 词的 `_classify_irrelevant_input`。 + - 修改②(HANDLER_ONLY 改为 LLM 输出驱动):查差旅标准不再由 gate_classify 规则命中,改为 LLM 返回 task_type=query_travel_standard 后,`_route_after_model_intent` 检查 scene.route=HANDLER_ONLY → 转 execute_scene_handler。`execute_scene_handler` 优先使用 LLM 生成的 task(含 ontology_fields)。 + - 修改③(图拓扑简化):gate_classify 只输出 off_topic|resume|model_intent 三种。删掉 handler_only/ambiguous 边。 + - 验证:后端 **76 passed**;端到端 7 场景全通过——①你好→off_topic、②123→off_topic、③下周去上海→llm_function_call(之前被误杀!)、④P5武汉住宿标准→scene_handler:query_travel_standard、⑤再提交→context_resume、⑥出差并且报销招待费→llm_function_call 返回 2 task(expense_application+reimbursement)。 + - 影响:意图识别全部交给 LLM,规则只保留闲聊拦截和 resume 兜底两个不可替代职责。加新场景只需注册 SceneDescriptor(prompt_fragment+ontology_fields+handler),LLM 的 system prompt 和 schema 自动包含,**不加任何关键词规则,不动门控代码**。 ## TODO diff --git a/server/src/app/services/steward_graph_planner.py b/server/src/app/services/steward_graph_planner.py index 5a39f8e..1229984 100644 --- a/server/src/app/services/steward_graph_planner.py +++ b/server/src/app/services/steward_graph_planner.py @@ -158,36 +158,36 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr message = str(state.get("message") or "").strip() steward_state = state.get("steward_state") or {} - # ① resume 门(优先:即使消息不命中业务关键词,只要是"再提交"类话术就尝试恢复) + # ① 闲聊拦截(极轻量:greeting + meaningless,省 LLM 成本,不依赖业务关键词) + # 不用 _classify_irrelevant_input(那套带 94 词判断,会误杀"下周去上海"等正常业务输入) + if self._is_lightweight_off_topic(message, request): + return {"gate_decision": "off_topic", "gate_scene_id": None} + + # ② resume 门(用户说"再提交" + state 有可恢复 flow → 确定性恢复) resume_scene = should_resume_recent_task(message, steward_state) if resume_scene: return {"gate_decision": "resume", "gate_scene_id": resume_scene} - # ② off_topic 门:复用成熟的 _classify_irrelevant_input(含城市名/时间词/金额词等 94 词 + greeting/meaningless 细分) - scenario = self._classify_irrelevant_input(message, request) - if scenario is not None: - return {"gate_decision": "off_topic", "gate_scene_id": None} - - # ③ 规则匹配门(按 priority 遍历,命中 CHOICE 规则的) - for scene in REGISTRY.scenes_sorted_by_priority(): - if scene.gate != GateRule.CHOICE: - continue - if not scene.signal_keywords: - continue - compact = _compact_text(message) - if any(kw in compact for kw in scene.signal_keywords): - route = _scene_route_to_gate_decision(scene.route) - return {"gate_decision": route, "gate_scene_id": scene.scene_id} - - # ④ LLM 门(规则未命中) - # 走 model_intent 时,如 state 已有 active_flow 且 LLM 准备 fallback,可优先做 candidate_flow - if self._looks_like_ambiguous_travel_flow( - message, self._resolve_base_date_from_request(request), request - ): - return {"gate_decision": "ambiguous", "gate_scene_id": None} - + # ③ 其他全部走 LLM(不再有规则匹配门;LLM function call 是唯一的意图识别者) return {"gate_decision": "model_intent", "gate_scene_id": None} + def _is_lightweight_off_topic(self, message: str, request: StewardPlanRequest) -> bool: + """极轻量闲聊拦截:只拦 greeting 和 meaningless,不做业务相关性判断。 + + 有附件时一定不是闲聊(附件意味着用户有业务诉求)。 + 业务相关性交给 LLM 判断,规则只挡掉绝对无关的输入省 LLM 成本。 + """ + if request.attachments: + return False + compact = _compact_text(message) + if not compact: + return True + if self._looks_like_greeting(compact): + return True + if self._looks_like_meaningless(compact): + return True + return False + def _execute_scene_handler(self, state: StewardGraphState) -> dict[str, Any]: """HANDLER_ONLY 路由:不调 LLM,直接执行 scene 的 handler。 @@ -205,7 +205,12 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr ) import time + # scene_id 优先从 gate_scene_id 取(兼容旧行为),否则从 plan.tasks[0].task_type 取(LLM 驱动) scene_id = state.get("gate_scene_id") + if not scene_id: + plan = state.get("plan") + if isinstance(plan, StewardPlanResponse) and plan.tasks: + scene_id = str(plan.tasks[0].task_type or "").strip() scene = REGISTRY.get(scene_id or "") if scene_id else None if scene is None or scene.handler is None: plan = self._build_rule_fallback_graph_plan(state) @@ -217,9 +222,15 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr return {"plan": plan} # 构造 handler 期望的 StewardActionExecuteRequest - from app.services.steward_action_contracts import StewardActionPlanBuilder - builder = StewardActionPlanBuilder() - if scene.action_steps_builder is not None: + # 优先使用 LLM 已生成的 task(含 ontology_fields),否则构造最小 task + existing_plan = state.get("plan") + llm_task = None + if isinstance(existing_plan, StewardPlanResponse) and existing_plan.tasks: + llm_task = existing_plan.tasks[0] + + if llm_task is not None: + task = llm_task + elif scene.action_steps_builder is not None: task = StewardTask( task_id=f"task_handler_{int(time.time() * 1000)}", task_type=scene.scene_id, @@ -232,27 +243,43 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr missing_fields=[], confirmation_required=False, ) - action_steps = scene.action_steps_builder(task) else: - action_steps = [] + task = StewardTask( + task_id=f"task_handler_{int(time.time() * 1000)}", + task_type=scene.scene_id, + assigned_agent=scene.assigned_agent or "policy_query_assistant", + title=scene.label, + summary=str(request.message or "").strip(), + status="delegated", + requested_action="preview", + ontology_fields={}, + missing_fields=[], + confirmation_required=False, + ) - # 构造一个最小 action step 用于构造 handler request + # 构造 action steps(优先用 scene 的 builder,否则最小 step) + action_steps: list[StewardActionStep] = [] + if scene.action_steps_builder is not None: + try: + action_steps = list(scene.action_steps_builder(task) or []) + except Exception: + action_steps = [] if not action_steps: action_steps = [StewardActionStep( step_id=f"handler_{int(time.time() * 1000)}", action_type=scene.side_effect_actions[0] if scene.side_effect_actions else scene.scene_id, label=scene.label, - target_task_id="", + target_task_id=task.task_id, status="planned", requires_confirmation=False, - payload={}, + payload={"task_id": task.task_id, "ontology_fields": task.ontology_fields}, )] step = action_steps[0] action_request = StewardActionExecuteRequest( action_type=step.action_type, message=str(request.message or "").strip(), - task=task if scene.action_steps_builder else None, + task=task, ) try: @@ -459,7 +486,7 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr graph.add_node("build_pending_flow_plan", self._build_pending_flow_fallback_graph_plan) graph.add_node("attach_action_steps", self._attach_action_steps) - # 拓扑 + # 拓扑(P2:LLM 驱动,gate_classify 只输出 off_topic|resume|model_intent) graph.add_edge(START, "load_context") graph.add_edge("load_context", "gate_classify") graph.add_conditional_edges( @@ -467,13 +494,10 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr self._route_after_gate_classify, { "off_topic": "build_off_topic_plan", - "handler_only": "execute_scene_handler", "resume": "resume_recent_task", - "ambiguous": "build_pending_flow_plan", "model_intent": "prepare_context", }, ) - graph.add_edge("execute_scene_handler", "attach_action_steps") graph.add_edge("resume_recent_task", "attach_action_steps") graph.add_conditional_edges( "prepare_context", @@ -484,18 +508,21 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr "fallback": "build_rule_fallback_plan", }, ) + # detect_model_intent 成功后:HANDLER_ONLY 类(task_type 对应 scene.route=HANDLER_ONLY) + # 转 execute_scene_handler;其他转 attach_action_steps graph.add_conditional_edges( "detect_model_intent", self._route_after_model_intent, { "done": "attach_action_steps", + "handler_only": "execute_scene_handler", "off_topic": "build_off_topic_plan", "fallback": "build_rule_fallback_plan", }, ) + graph.add_edge("execute_scene_handler", "attach_action_steps") graph.add_edge("build_off_topic_plan", "attach_action_steps") graph.add_edge("build_rule_fallback_plan", "attach_action_steps") - graph.add_edge("build_pending_flow_plan", "attach_action_steps") graph.add_edge("attach_action_steps", END) return graph.compile() @@ -589,7 +616,17 @@ class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtr @staticmethod def _route_after_model_intent(state: StewardGraphState) -> str: - if isinstance(state.get("plan"), StewardPlanResponse): + plan = state.get("plan") + if isinstance(plan, StewardPlanResponse): + # LLM 成功:检查第一个 task 是否对应 HANDLER_ONLY 场景(查询类,直接执行 handler) + if plan.tasks: + from app.services.scenes import REGISTRY + from app.services.scenes.gate_rules import SceneRoute + + first_task_type = str(plan.tasks[0].task_type or "").strip() + scene = REGISTRY.get(first_task_type) + if scene is not None and scene.route == SceneRoute.HANDLER_ONLY: + return "handler_only" return "done" if state.get("scenario") is not None: return "off_topic"