mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:01:57 +00:00
重排给足输出预算;失败提示分清是哪种失败
拿真 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 字;个数不符 → 说明给了几个、候选几个;混了非数字 → 指出是哪个值。
测试里有一条专门断言空回复的提示中不出现「解析」二字。
This commit is contained in:
@@ -91,9 +91,22 @@ async def run_retrieve(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
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, _model_spec(ctx))
|
||||
model, _ = await get_chat_model(session, spec)
|
||||
except ProviderNotConfigured as e:
|
||||
degraded.append(f"重排用不了:{e}")
|
||||
model = None
|
||||
|
||||
@@ -28,26 +28,36 @@ _PROMPT = """给下面每个片段打分:它对回答这个问题有多大帮
|
||||
0 表示完全无关,10 表示直接回答了问题。"""
|
||||
|
||||
|
||||
def _parse_scores(text: str, n: int) -> list[float] | None:
|
||||
"""从模型回复里抠出分数数组。
|
||||
class Unusable(ValueError):
|
||||
"""回复用不了。message 就是要交给用户的那句话。"""
|
||||
|
||||
|
||||
def _parse_scores(text: str, n: int) -> list[float]:
|
||||
"""从模型回复里抠出分数数组。用不了就抛 Unusable,message 说清楚是哪种用不了。
|
||||
|
||||
模型偶尔会加围栏或者说两句废话,所以不直接 json.loads 整段;
|
||||
但也不做过度容错——解析不出来就退回原顺序,比按半截数据重排安全。
|
||||
"""
|
||||
if not text.strip():
|
||||
# 实测 deepseek-v4-pro:失败那次 out_tokens 正好等于 max_tokens,
|
||||
# 输出预算被耗光后正文什么都不剩。报"解析不出来"会把人引去查 JSON 格式,
|
||||
# 而该调的是 max_tokens——llm.py 里对同一个现象也是这么说的
|
||||
raise Unusable("模型没有输出正文,多半是输出 token 预算用完了。把节点上的最大输出 token 调大")
|
||||
match = re.search(r"\{[^{}]*\"scores\"\s*:\s*\[[^\]]*\][^{}]*\}", text, re.S)
|
||||
if not match:
|
||||
return None
|
||||
raise Unusable(f"模型回复里找不到 scores:{text.strip()[:80]}")
|
||||
try:
|
||||
scores = json.loads(match.group(0)).get("scores")
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
raise Unusable(f"scores 不是合法 JSON:{e}") from e
|
||||
if not isinstance(scores, list) or len(scores) != n:
|
||||
# 个数对不上就没法一一对应,硬凑只会把顺序搅乱
|
||||
return None
|
||||
raise Unusable(f"给了 {len(scores) if isinstance(scores, list) else '?'} 个分数,"
|
||||
f"候选有 {n} 个,对不上")
|
||||
out: list[float] = []
|
||||
for s in scores:
|
||||
if not isinstance(s, (int, float)):
|
||||
return None
|
||||
raise Unusable(f"分数里混了非数字:{s!r}")
|
||||
out.append(float(s))
|
||||
return out
|
||||
|
||||
@@ -80,16 +90,15 @@ async def rerank(
|
||||
if isinstance(text, list): # Anthropic 的块状 content
|
||||
text = "".join(b.get("text", "") for b in text if isinstance(b, dict))
|
||||
scores = _parse_scores(str(text), len(hits))
|
||||
except Unusable as e:
|
||||
if on_note:
|
||||
on_note(f"重排没用上,按初筛顺序返回:{e}")
|
||||
return hits[:top_n]
|
||||
except Exception as e: # noqa: BLE001
|
||||
if on_note:
|
||||
on_note(f"重排失败,按初筛顺序返回:{type(e).__name__}: {e}")
|
||||
return hits[:top_n]
|
||||
|
||||
if scores is None:
|
||||
if on_note:
|
||||
on_note("重排结果解析不出来,按初筛顺序返回")
|
||||
return hits[:top_n]
|
||||
|
||||
ranked = sorted(zip(hits, scores), key=lambda t: t[1], reverse=True)
|
||||
out: list[dict[str, Any]] = []
|
||||
for hit, score in ranked[:top_n]:
|
||||
|
||||
@@ -383,3 +383,35 @@ async def test_deleting_a_document_takes_its_index_rows() -> None:
|
||||
after = int((await session.execute(select(func.count(ChunkTerm.id)).where(
|
||||
ChunkTerm.collection == "idx_del"))).scalar_one())
|
||||
assert after == 0, f"文档删了但倒排还留着 {after} 行"
|
||||
|
||||
|
||||
async def test_an_empty_reply_is_not_called_a_parse_error() -> None:
|
||||
"""推理型模型把输出预算花光时正文是空的——实测 deepseek-v4-pro 失败那次
|
||||
out_tokens 正好等于 max_tokens。
|
||||
|
||||
报"解析不出来"会把人引去查 JSON 格式,而该调的是最大输出 token。
|
||||
llm.py 里对同一个现象也是这么说的。
|
||||
"""
|
||||
from app.memory.rerank import rerank
|
||||
|
||||
notes: list[str] = []
|
||||
out = await rerank(_Scorer(""), "问题", _hits(3), top_n=3, on_note=notes.append)
|
||||
assert [h["chunk_id"] for h in out] == ["c0", "c1", "c2"]
|
||||
assert "token" in notes[0] and "调大" in notes[0], notes[0]
|
||||
assert "解析" not in notes[0]
|
||||
|
||||
|
||||
async def test_each_kind_of_bad_reply_says_which_kind() -> None:
|
||||
"""四种坏法要分得开,否则排查时只知道"重排没用上"。"""
|
||||
from app.memory.rerank import rerank
|
||||
|
||||
cases = {
|
||||
'': 'token', # 空回复
|
||||
'随便说点什么': 'scores', # 找不到 scores
|
||||
'{"scores": [1, 2]}': '对不上', # 个数不符
|
||||
'{"scores": ["高", "低", "中"]}': '非数字', # 类型不对
|
||||
}
|
||||
for reply, want in cases.items():
|
||||
notes: list[str] = []
|
||||
await rerank(_Scorer(reply), "问题", _hits(3), top_n=3, on_note=notes.append)
|
||||
assert want in notes[0], f"回复 {reply!r} 给的提示是「{notes[0]}」,认不出是哪种坏法"
|
||||
|
||||
Reference in New Issue
Block a user