Files
AgentLab/backend/app/engine/nodes/knowledge.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

182 lines
7.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.state import GraphState, template_context
from app.memory import kb, store
async def run_memory(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""长期记忆读写节点。
把记忆做成画布上的一个节点,而不是藏在 agent 内部,是为了让"什么时候记、
记什么、记到哪个 scope"变成可见、可调的编排决策。
"""
action = ctx.cfg("action", "recall") # recall | write | clear
scope = ctx.render_str(ctx.cfg("scope", "") or ctx.run.memory_scope, state)
async with SessionLocal() as session:
if action == "recall":
query = ctx.render_str(ctx.cfg("query", "{{ last_message }}"), state)
limit = int(ctx.cfg("limit", 5) or 5)
hits = await store.recall(
session, scope=scope, query=query, limit=limit, kind=ctx.cfg("kind") or None
)
text = "\n".join(f"- {h['content']}" for h in hits)
result: dict[str, Any] = {
"memories": hits,
"count": len(hits),
"text": text,
"scope": scope,
}
elif action == "write":
content = ctx.render_str(ctx.cfg("content", ""), state)
if not content.strip():
raise NodeError(ctx.node.id, "记忆写入节点没有内容")
item = await store.remember(
session,
scope=scope,
content=content,
kind=ctx.cfg("kind", "fact") or "fact",
importance=float(ctx.cfg("importance", 0.5) or 0.5),
meta={"run_id": ctx.run.run_id, "node_id": ctx.node.id},
)
result = {"id": item.id, "saved": True, "scope": scope, "content": item.content}
elif action == "clear":
removed = await store.clear_scope(session, scope)
result = {"cleared": removed, "scope": scope}
else:
raise NodeError(ctx.node.id, f"未知的记忆操作:{action}")
# 写入必须看得见:这是往长期记忆里存东西,会影响以后每一次对话。
# 以前发的是 info 日志,而解码层丢弃所有 info,等于系统背着用户记了东西
ctx.emit(
EventType.MEMORY_END,
action=action,
scope=scope,
count=result.get("count", result.get("cleared", 1 if action == "write" else 0)),
# 记了什么要原样说出来,只报"写了 1 条"等于没说
content=result.get("content", "")[:300],
)
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: result.get("text", result)}
return updates
async def run_retrieve(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""知识库检索节点。输出既有结构化命中列表,也有拼好的上下文文本。"""
query = ctx.render_str(ctx.cfg("query", "{{ last_message }}"), state)
if not query.strip():
raise NodeError(ctx.node.id, "检索节点没有查询内容")
collection = ctx.render_str(ctx.cfg("collection", "") or ctx.run.collection, state)
limit = int(ctx.cfg("limit", 5) or 5)
# 不写死 0.5:没配置就跟着 embedder 的能力走(见 embeddings.default_alpha)
raw_alpha = ctx.cfg("alpha")
alpha = float(raw_alpha) if raw_alpha not in (None, "") else None
# 要重排就先多捞一些:重排只能在初筛给出的候选里挑,初筛只给 5 条的话
# 它最多把这 5 条换个顺序,第 6 条是对的也救不回来
mode = ctx.cfg("rerank", "off") or "off"
fetch = max(limit, 20) if mode == "model" else limit
degraded: list[str] = []
async with SessionLocal() as session:
hits = await kb.search(
session, collection=collection, query=query, limit=fetch, alpha=alpha,
on_degrade=degraded.append,
)
if mode == "model" and hits:
from app.engine.nodes.llm import _model_spec
from app.memory.rerank import rerank
from app.providers.factory import ProviderNotConfigured, get_chat_model
# 重排有自己的预算,不跟节点上那个走:
#
# 它的输出只有一行几十字的 JSON,但模型为了读完 20 条候选会花掉上千个
# 输出 token——实测 deepseek-v4-pro 单次用 800~1600 个,一旦顶到上限,
# 正文就什么都不剩(失败那次 out_tokens 正好等于 max_tokens)。
#
# thinking=off 对 Anthropic 有效;openai_compatible 那条路径不读这个字段
# (factory.py 只在 anthropic 分支里设 thinking),所以真正兜住的是
# 上面那个预算。打分任务本来也不需要思考模式。
spec = _model_spec(ctx).model_copy(update={
"max_tokens": max(int(ctx.cfg("max_tokens") or 0), 4096),
"thinking": "off",
})
try:
async with SessionLocal() as session:
model, _ = await get_chat_model(session, spec)
except ProviderNotConfigured as e:
degraded.append(f"重排用不了:{e}")
model = None
hits = await rerank(model, query, hits, top_n=limit, on_note=degraded.append)
else:
hits = hits[:limit]
for note in degraded:
# 检索悄悄少了一半能力,不说的话用户只会觉得"最近搜得不准"
ctx.emit(EventType.LOG, level="warn", message=note, code="retrieve_degraded")
min_score = float(ctx.cfg("min_score", 0.0) or 0.0)
hits = [h for h in hits if h["score"] >= min_score]
# 拼成可以直接塞进 prompt 的上下文,带编号方便模型引用出处
context_text = "\n\n".join(
f"[{i + 1}] {h['title']}(片段 {h['ordinal']})\n{h['content']}"
for i, h in enumerate(hits)
)
# 命中落工件库。SQL 取数、工具调用、agent 工具调用都有快照,检索一直没有,
# 于是结论只要来自知识库,"这个数是哪来的"这条链就断在这儿
from app.core.artifact_store import put_json
from app.memory.embeddings import default_alpha, embedder_id
snapshot: str | None = None
try:
snapshot = await put_json(
{
"query": query, "collection": collection,
"alpha": alpha if alpha is not None else default_alpha(),
"min_score": min_score,
"embedder": embedder_id(), "rerank": mode, "degraded": degraded,
"hits": hits,
},
kind="retrieval_snapshot",
run_id=ctx.run.run_id, node_id=ctx.node.id,
)
except Exception: # noqa: BLE001 - 存不下不影响这次检索本身
snapshot = None
ctx.emit(
EventType.RETRIEVE_END,
collection=collection,
query=query[:200],
count=len(hits),
top_score=hits[0]["score"] if hits else 0,
reranked=mode == "model",
degraded=bool(degraded),
artifact=snapshot,
)
result = {
"hits": hits,
"count": len(hits),
"text": context_text,
"query": query,
"collection": collection,
"artifact": snapshot,
}
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
var_name = ctx.cfg("assign_to", "")
if var_name:
updates["vars"] = {var_name: context_text}
return updates