Files
AgentLab/backend/app/engine/nodes/control.py
T
ThearchyHelios cc608f56ec 跑完先过一道复核再交给你:异常说破、数字不许现编、出错自动改一次
「跑完了」和「答得对」之间一直没有任何一层。库里 264 条 succeeded 里,
14 条带着 warn、4 条 agent 步数用满,而交到用户手上的答案对此只字不提。
最糟的一次(run dd9927e6)交出去的干脆是「本轮只执行了第一个工具」——
一句内部管道文案冒充结论。

出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上,
还要配口径卡提供受控指标集;问数据页生成的图基本都是 input → agent → output,
走不到那条路。所以补一层轻的。

**干净的运行一次模型都不调。** 先用规则扫一遍事件流(engine/review.py:scan),
没扫出东西就到此为止。实测 15ms 返回。这是整层设计的前提,测试里用
「monkeypatch 一个会抛异常的模型,断言它没被碰过」来守。

**分类挂在 code 上,不是中文消息串。** 12 处 warn 发射点都带上 code。
这样「流式失败,回退非流式」能归成 benign 直接跳过——它对答案内容零影响,
为它叫一次模型等于每次网络抖一下就多花一笔;而「步数用满」归 broken。
原来这两者在事件流里长得一模一样。工具报错之后同一个工具又跑成了,
算 degraded 不算 broken——否则参数自愈每修一次就报一次「答案不可信」。

**复核不许现编数字。** 它手上只有原答案和一份异常清单,没有任何原始数据,
写出来的新数字只能是凭空来的。直接复用 issuance.trace_numbers,把原答案的
数字当受控指标集,对不上就整条退回原答案、只留说明。硬拦截,不是提示。

**规则管重试资格,模型只有否决权。** 一开始写成两边取并,实测 loop_limit
时模型判 retry=true——而重跑一遍循环还是会撞上限,白烧一次建图加一次跑图。
反过来之后,「检索什么都没查到」这类也不会被模型强推重跑。

**重试带着失败的那张图,不是推倒重来。** 原来传 base_graph=null,模型只看到
「原问题 + 一句失败原因」,很可能原样再建出同一张坏图。而且它连自己上次建了
什么都看不到——会话历史按 status != running 过滤,正在重试的这一轮恰好被
排除在外。现在交回去走后端的改图分支,调大步数、改正参数就是改一个字段。
上限一次。

**答案等复核完成再显示。** 端到端抓到的:答案先摆出来,十秒后说明才追上去——
这十秒里用户读的是一个没人核对过的结论,正是要修的那件事本身。改成落库立刻
(防关标签页丢结果)、显示等复核,并给复核加 25s 上限。说明排在成果上方,
broken 用报警色、degraded 用普通边框——混成一个样子用户就学会了一概忽略。

复核结论跟着轮次落库,刷新后还在;也进下一轮的对话历史,否则模型会拿一个
已知不可信的结论当事实继续往下推。

验证:后端 242 passed(新增 test_review.py 25 条);tsc 干净;
check-decode / check-stream / check-ui 全过,check-stream 新增 9 条渲染断言。
两条端到端都在真浏览器 + 真模型上跑通:检索降级那次说明正确排在答案上方;
重试那次建图请求 2 次、第二次 base_graph 带着失败的 3 个节点、第一版没漏出去。

复核结果不是事件流,decode.ts 翻译不到它,所以渲染测试放在 check-stream。
2026-09-20 11:18:53 +08:00

185 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
from typing import Any
from app.core.events import EventType
from app.db.base import SessionLocal
from app.engine.context import NodeContext, NodeError
from app.engine.expressions import ExpressionError, eval_condition
from app.engine.state import GraphState, message_text, template_context
from app.providers.factory import ModelSpec, get_chat_model
# 分支和循环的"决定"写在 nodes[node_id] 里,编译器的路由函数再读出来。
# 路由函数本身不能有副作用,所以判断必须发生在节点执行阶段。
DECISION_KEY = "__decision__"
async def run_branch(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""条件分支。两种模式:表达式判断,或让模型做语义分类。"""
cases = ctx.cfg("cases", []) or []
if not cases:
raise NodeError(ctx.node.id, "分支节点没有配置任何条件")
mode = ctx.cfg("mode", "expression")
tctx = template_context(state)
chosen = "default"
reason = ""
if mode == "llm":
chosen, reason = await _classify_with_llm(state, ctx, cases)
else:
for case in cases:
expr = case.get("condition", "")
key = case.get("key") or case.get("label") or "case"
if not expr:
continue
try:
if eval_condition(expr, tctx):
chosen, reason = key, f"命中条件:{expr}"
break
except ExpressionError as e:
raise NodeError(ctx.node.id, f"分支条件写错了({expr}):{e}") from e
else:
reason = "所有条件都不满足,走 default"
ctx.emit(EventType.EDGE_TAKEN, branch=chosen, reason=reason, mode=mode)
return {
"nodes": {ctx.node.id: {DECISION_KEY: chosen, "branch": chosen, "reason": reason}},
}
async def _classify_with_llm(
state: GraphState, ctx: NodeContext, cases: list[dict[str, Any]]
) -> tuple[str, str]:
"""让模型在给定选项里挑一个。用结构化输出保证它只会吐出合法的 key。"""
options = [
{"key": c.get("key") or f"case{i}", "描述": c.get("label") or c.get("condition") or ""}
for i, c in enumerate(cases)
]
keys = [o["key"] for o in options] + ["default"]
subject = ctx.render_str(ctx.cfg("input", "{{ last_message }}"), state)
instruction = ctx.render_str(ctx.cfg("instruction", ""), state)
prompt = (
f"{instruction}\n\n" if instruction else ""
) + (
"请把下面的内容归入最合适的一类。\n\n"
f"可选类别:\n{json.dumps(options, ensure_ascii=False, indent=2)}\n\n"
f"内容:\n{subject}\n\n"
"只返回 JSON:{\"key\": <类别key>, \"reason\": <一句话理由>}"
)
async with SessionLocal() as session:
model, _ = await get_chat_model(
session,
ModelSpec(
provider=ctx.cfg("provider"),
model=ctx.cfg("model"),
max_tokens=512,
),
)
schema = {
"title": "classify", # langchain 靠 title 识别 JSON Schema dict
"type": "object",
"properties": {
"key": {"type": "string", "enum": keys},
"reason": {"type": "string"},
},
"required": ["key"],
}
try:
value = await model.with_structured_output(schema).ainvoke(prompt)
if not isinstance(value, dict):
value = json.loads(message_text(value))
except Exception: # noqa: BLE001 - 结构化不支持就退回自由文本再解析
raw = message_text(await model.ainvoke(prompt))
value = {}
for key in keys:
if key in raw:
value = {"key": key, "reason": raw[:200]}
break
chosen = str(value.get("key") or "default")
if chosen not in keys:
chosen = "default"
return chosen, str(value.get("reason", ""))
async def run_loop(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""循环控制。
画布上的接法:loop 节点的 `body` 出口连向循环体,循环体最后一个节点再连回 loop 节点;
`done` 出口连向循环结束后要走的分支。每进一次这个节点就推进一次计数。
"""
mode = ctx.cfg("mode", "foreach")
node_id = ctx.node.id
loops = dict(state.get("loops") or {})
cursor = int((loops.get(node_id) or {}).get("index", 0)) if isinstance(loops.get(node_id), dict) else 0
max_iter = int(ctx.cfg("max_iterations", 10) or 10)
tctx = template_context(state)
item_var = ctx.cfg("item_var", "item")
updates_vars: dict[str, Any] = {}
keep_going = False
current_item: Any = None
total = None
if mode == "foreach":
items = ctx.render(ctx.cfg("items", ""), state)
if isinstance(items, str):
try:
items = json.loads(items)
except json.JSONDecodeError:
items = [line for line in items.splitlines() if line.strip()]
if not isinstance(items, list):
items = [items] if items not in (None, "") else []
total = len(items)
keep_going = cursor < len(items) and cursor < max_iter
if keep_going:
current_item = items[cursor]
updates_vars = {item_var: current_item, f"{item_var}_index": cursor}
else: # while
condition = ctx.cfg("condition", "")
if cursor >= max_iter:
keep_going = False
elif not condition:
keep_going = False
else:
try:
keep_going = eval_condition(condition, tctx)
except ExpressionError as e:
raise NodeError(ctx.node.id, f"循环条件写错了:{e}") from e
hit_limit = cursor >= max_iter
if hit_limit:
ctx.emit(
EventType.LOG, level="warn",
message=f"循环达到上限 {max_iter} 次,强制退出", code="loop_limit",
)
decision = "body" if keep_going else "done"
ctx.emit(
EventType.EDGE_TAKEN,
branch=decision,
iteration=cursor,
total=total,
mode=mode,
)
result = {
DECISION_KEY: decision,
"iteration": cursor,
"total": total,
"item": current_item,
"finished": not keep_going,
}
updates: dict[str, Any] = {
"nodes": {node_id: result},
"loops": {node_id: {"index": cursor + 1 if keep_going else cursor}},
}
if updates_vars:
updates["vars"] = updates_vars
return updates