mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:01:58 +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。
182 lines
7.5 KiB
Python
182 lines
7.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.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
|