Files
AgentLab/backend/app/engine/nodes/knowledge.py
T
ThearchyHelios 2a1040bbfc 重排给足输出预算;失败提示分清是哪种失败
拿真 provider(deepseek-v4-pro,千帆网关)跑了一遍评测集,重排确实有用:

              hit@1   hit@5
    初筛        88%    100%
    重排后     100%    100%

变动的两题都是语义类的,正好是本地哈希向量的短板——BM25 把「权限」的字面
命中排给了「数据导出」(那篇里有「export 权限」),模型读懂了问的是「谁有权
改」。字面类原本就 100%,没有被重排搞坏。

但 16 次里有 2 次退回了初筛顺序。查下来模型回的是**空字符串**,而失败那次的
out_tokens 正好等于 max_tokens——重排的输出只有一行几十字的 JSON,但模型为了
读完 20 条候选要花 800~1600 个输出 token,顶到上限后正文什么都不剩。llm.py
里对同一个现象早有记录:「思考型模型把额度全花在推理上了,正文是空的」。

所以重排改成有自己的预算(至少 4096),不跟节点上那个走。补跑 16/16 全部成功。

顺带纠正一处判断:thinking=off 救不了这个 provider。实测关思考那组照样在
out_tokens=2048 时空手——factory.py 只在 anthropic 分支里读 thinking 字段,
openai_compatible 整条路径不碰它。仍然传 off,因为对 Anthropic 有效、而且打分
任务本来不需要思考模式,但它不是这次的解药,注释里写明了。

失败提示原来四种坏法统一报「解析不出来」,会把人引去查 JSON 格式,而该调的是
token。现在分开说:空回复 → 「把最大输出 token 调大」;找不到 scores → 带上
原文前 80 字;个数不符 → 说明给了几个、候选几个;混了非数字 → 指出是哪个值。
测试里有一条专门断言空回复的提示中不出现「解析」二字。
2026-09-19 11:09:04 +08:00

174 lines
7.1 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}")
ctx.emit(EventType.LOG, level="info",
message=f"记忆 {action} @ {scope}:{result.get('count', result.get('cleared', 1))} 条")
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)
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