mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:01:59 +00:00
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。
185 lines
6.5 KiB
Python
185 lines
6.5 KiB
Python
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
|