时间线认得核查模型的输出被截断(verify_truncated):标题说「核查模型的输出达到上限(N token)被截断」,副标题说这次核查未能完成或已按截断前查到的结果收尾,下一步指到换不带推理的模型,入口落到节点的模型

decode.ts 按结构化字段(max_tokens、delivered)解读,不从原话里认,缺字段时只给不带数的标题;文案放在 terms.ts 新的 VERIFY_TRUNCATED_TEXT。check-decode 加一节:先核对 verify_agent.py 还是这么发的,再核对三种取值(没交出结果、退回 4096 后交了结果、缺字段)的标题、副标题、下一步、入口和原话。

跑完之后的复核层(engine/review.py 的 WARN_CODES)登记 verify_truncated:同 verify_limit 记成它自己的 kind、degraded,不落成笼统的 warn,也不触发「重跑一次大概率能好」。
This commit is contained in:
ThearchyHelios
2026-10-06 13:06:18 +08:00
parent 4a3de10d38
commit 2c2672cc04
5 changed files with 91 additions and 3 deletions
+3
View File
@@ -128,6 +128,9 @@ WARN_CODES: dict[str, tuple[str, str]] = {
# 核查 agent 自己触顶、按已有结果收尾:多半和节点自己的 *_limit 说的是同一次触顶,照样是缺口,记成它自己的 kind,
# 不落成笼统的 warn——步数上限(max_steps)只有它说
"verify_limit": ("verify_limit", DEGRADED),
# 核查 agent 的输出到了上限被截断(推理模型的思考也算在额度里):没交成结果的由节点按无法复算、未能反查记缺口,交了的
# 可能不完整。同 verify_limit,是缺口不是断裂,记成它自己的 kind:说得清是输出额度的问题,换个模型就好
"verify_truncated": ("verify_truncated", DEGRADED),
}
# 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。
+11
View File
@@ -27,6 +27,7 @@ from openai.types.chat import ChatCompletion
from app.core.events import EventType
from app.engine import judge as judging
from app.engine import review as rv
from app.engine import verify_agent
from app.engine.inquiry import _error_words
from app.engine.nodes.challenge import _claim_of
@@ -330,5 +331,15 @@ async def test_the_reason_reaches_dual_check_challenge_and_inquiry(monkeypatch,
assert _error_words(out, timeout_s=60, daily_max_usd=1.0) == expected
def test_the_review_layer_knows_the_truncation_log():
"""跑完之后的复核层(engine/review.py)认得 verify_truncated:同 verify_limit,是缺口不是断裂,kind 是它自己,
不落成笼统的 warn;不触发「重跑一次大概率能好」——同样的模型再跑一遍还是撞同一堵墙。"""
assert rv.WARN_CODES["verify_truncated"] == ("verify_truncated", rv.DEGRADED)
event = {"type": str(EventType.LOG), "node_id": "dc",
"data": {"level": "warn", "code": "verify_truncated", "message": f"{CUT_16K},核查未能完成。{ADVICE}"}}
[signal] = rv.scan([event], {"结论": "本周订单 120 单。"})
assert (signal.kind, signal.severity) == ("verify_truncated", rv.DEGRADED) and not rv.should_retry([signal])
def test_the_reply_shapes_used_here_are_messages():
assert isinstance(cut_reply(), BaseMessage) and isinstance(anthropic_cut()["raw"], BaseMessage)
+13
View File
@@ -1039,6 +1039,19 @@ export const AGENT_GUARD_TEXT = {
unlimitedShort: '不限',
} as const
/**
* 时间线上「核查模型的输出到了上限被截断」那一行(engine/verify_agent.py 的 verify_truncated 日志,双盲复算、反方审稿的
* 核查 Agent 发)。推理模型的思考也算在输出额度里,额度用完时回复是空的或只写了一半:照实说是输出额度不够,不说成
* 「没查到」「格式不对」,下一步指到换模型。delivered:截断发生在查询过程中、已按截断前查到的结果收尾(结果可能不完整);
* undelivered:这次核查没有给出结果,节点按无法复算、未能反查处理
*/
export const VERIFY_TRUNCATED_TEXT = {
title: (maxTokens?: number) => `核查模型的输出达到上限${maxTokens ? `(${formatNumber(maxTokens)} token)` : ''}被截断`,
delivered: '已按截断前查到的结果收尾,结果可能不完整',
undelivered: '这次核查未能完成',
next: '通常是推理占满了输出额度。请在节点上指定不带推理的模型,或在「设置 → 偏好设置 → 核查」中更换核查模型',
} as const
/**
* 上传表格:数据页「表格」标签的上传弹窗、导入回执和卡片。种类的说法和后端 data/tabular.py 的
* CONVERSION_KINDS、SHAPE_KINDS 对得上;后端以后多出来的种类,界面上只显示它给的那句话
+18 -3
View File
@@ -3,7 +3,7 @@ import type { NodeState, RunPhase } from './trace'
import { formatDuration, formatNumber } from '../lib/format'
import {
ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL,
CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, NODE_TYPE_LABEL,
CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, NODE_TYPE_LABEL, VERIFY_TRUNCATED_TEXT,
claimTally,
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
@@ -765,17 +765,19 @@ function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason:
* 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的
* (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒。
* 无法复算(dual_check_unchecked)、未能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是
* 期 4 补上的:以前认不得,只能照原话显示
* 期 4 补上的:以前认不得,只能照原话显示。核查模型的输出到了上限被截断(verify_truncated,两种节点共用的核查 Agent 发)
* 是期 5 验收前补上的
*/
const VERIFY_LOG_CODES = new Set([
'dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved',
'dual_check_catalog', 'dual_check_limit', 'dual_check_unchecked',
'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit',
'challenge_unreviewed', 'challenge_sources_lost',
'challenge_unreviewed', 'challenge_sources_lost', 'verify_truncated',
])
const DC = DUAL_CHECK_TEXT
const CL = CHALLENGE_TEXT.log
const VT = VERIFY_TRUNCATED_TEXT
/**
* 出具缺口里哪几条是双盲复算、反方审稿报上来的:出具契约给上游节点报的缺口加了「节点类型「节点名」:」的前缀
@@ -1214,6 +1216,19 @@ function explainLog(code: string | undefined, message: string, d: Record<string,
level: 'warn',
}
}
case 'verify_truncated': {
// 核查 Agent 的输出到了上限被截断(verify_agent.py):max_tokens 是那次调用的输出额度,delivered 说截断之后还交没交出
// 结果。读结构化字段,不从原话里认;缺了字段(别的服务端版本)只给不带数的标题。入口落到节点的模型:该换的是模型
const limit = typeof d.max_tokens === 'number' && d.max_tokens > 0 ? d.max_tokens : undefined
return {
title: VT.title(limit),
sub: d.delivered === true ? VT.delivered : d.delivered === false ? VT.undelivered : undefined,
next: VT.next,
fix: 'canvas',
fixField: 'model',
level: 'warn',
}
}
default:
return null
}
+46
View File
@@ -2319,6 +2319,52 @@ await section('核查类日志与出具缺口(期 4):无法复算、未能
rows.map((r) => `${r?.title}|${r?.sub ?? ''}`).join(' ; '))
})
await section('核查模型的输出到了上限被截断(verify_truncated):说清额度、交没交出结果,下一步指到换模型', async () => {
// 推理模型的思考也算在输出额度里:核查 Agent 的一次调用 finish_reason=length、没有工具调用也没有 JSON 时,以前双盲复算
// 记成「盲算没有查到这个数」、反方审稿记成「核查模型没有按结构交结果」。现在核查 Agent 另发 verify_truncated(max_tokens、
// delivered、purpose),时间线按结构化字段说人话,原话留在展开区。先核对后端源码还是这么发的
const verifyPy = readFileSync(`${root}backend/app/engine/verify_agent.py`, 'utf8')
check('后端发截断日志的格式还是夹具里这一种', verifyPy.includes('code="verify_truncated"') && verifyPy.includes('max_tokens=limit')
&& verifyPy.includes('delivered=delivered') && verifyPy.includes('purpose=purpose')
&& verifyPy.includes('TRUNCATED_ERROR = "{cut},核查未能完成。{advice}"'),
'改了 verify_agent.py 的字段或说法就同步改 decode.ts 的 explainLog 和这里的夹具')
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
const ADVICE = '通常是推理占满了输出额度,可在「设置 → 偏好设置 → 核查」中换用不带推理的模型'
const FAILED = `核查模型的输出达到上限(16384 token)被截断,核查未能完成。${ADVICE}`
const SETTLED = `核查模型的输出达到上限(16384 token)被截断,已按截断前查到的结果收尾,结果可能不完整。${ADVICE}`
const all = flatten(mod.decodeRun([
E(1, 'run.started', null, { total: 2 }),
E(2, 'node.started', 'dc', { node_type: 'dual_check', label: '复核销售额' }),
E(3, 'log', 'dc', { level: 'warn', code: 'verify_truncated', purpose: 'verify', max_tokens: 16384, delivered: false, message: FAILED }),
E(4, 'node.finished', 'dc', { duration_ms: 20 }),
E(5, 'node.started', 'ch', { node_type: 'challenge', label: '审月报' }),
E(6, 'log', 'ch', { level: 'warn', code: 'verify_truncated', purpose: 'verify_result', max_tokens: 4096, delivered: true,
message: SETTLED }),
// 缺字段、字段类型不对(别的服务端版本):只给不带数的标题,不瞎填
E(7, 'log', 'ch', { level: 'warn', code: 'verify_truncated', max_tokens: '16384', delivered: 'no', message: FAILED }),
E(8, 'node.finished', 'ch', { duration_ms: 30 }),
E(9, 'run.finished', null, { status: 'succeeded' }),
]))
const rows = all.filter((s) => s.code === 'verify_truncated')
const nodes = all.filter((s) => s.kind === 'node')
check('每条一行,挂在自己的节点下', rows.length === 3 && nodes.find((s) => s.nodeId === 'dc')?.children?.filter((x) => x.code === 'verify_truncated').length === 1
&& nodes.find((s) => s.nodeId === 'ch')?.children?.filter((x) => x.code === 'verify_truncated').length === 2, `${rows.length} 行`)
const [failed, settled, bare] = rows
check('没交出结果:标题说输出到了上限被截断、带额度,副标题说核查未能完成', failed?.title === '核查模型的输出达到上限(16,384 token)被截断'
&& failed.sub === '这次核查未能完成', `${failed?.title} / ${failed?.sub}`)
check('交了结果:额度照实写(退回小额度之后是 4,096),副标题说按截断前查到的结果收尾、可能不完整',
settled?.title === '核查模型的输出达到上限(4,096 token)被截断' && settled.sub === '已按截断前查到的结果收尾,结果可能不完整',
`${settled?.title} / ${settled?.sub}`)
check('……下一步指到换不带推理的模型,入口落到节点的模型,提醒色', rows.every((s) => !!s.next?.includes('不带推理的模型')
&& s.next.includes('设置 → 偏好设置 → 核查') && s.fix === 'canvas' && s.fixField === 'model' && s.level === 'warn'),
`${failed?.next} / ${failed?.fix} / ${failed?.fixField} / ${failed?.level}`)
check('……原话留在展开区', failed?.detail === FAILED && settled?.detail === SETTLED)
check('缺字段、类型不对:标题不带数,不写副标题,不出 undefined、NaN', bare?.title === '核查模型的输出达到上限被截断' && bare.sub === undefined
&& !/undefined|NaN/.test(`${bare?.title}${bare?.next ?? ''}`), `${bare?.title} / ${bare?.sub}`)
check('标题、副标题里没有内部代码', !rows.some((s) => /verify_truncated|max_tokens|delivered/.test(`${s.title}${s.sub ?? ''}${s.next ?? ''}`)))
check('节点照常完成', nodes.every((s) => s.status === 'done'))
})
await section('审批质询 approval.inquiry(期 4):一行「审批人质询」,标题带问题前 40 字,副标题说回答是否完成、查了几次', async () => {
// 前后端共用的约定:运行停在审批上时服务端追加 approval.inquiry,node_id 为空;data 里有问题、划选的原文、回答正文、
// 回答文档、成没成、触顶原因、查询(queries 和 inquiry_artifacts 两份)。以前这类事件落进「未识别的事件」