Yuxi 测试审计与发布门禁 Oracle 收敛:从"检查源码"到"验证运行时事实"的测试治理实践
【免费下载链接】Yuxi可私有部署的多租户知识智能体平台:统一 RAG、知识图谱、多智能体、MCP/Skills、沙盒与权限管理。Self-hosted knowledge agent platform for RAG, knowledge graphs and multi-agent workflows.项目地址: https://gitcode.com/GitHub_Trending/yu/Yuxi
导读
本文基于 Yuxi 仓库中的架构决策记录 2026-09-07-test-suite-audit-follow-up.md,完整还原一次针对测试套件的"第二轮独立审计":如何识别出 Prompt 测试未验证真实组装结果、默认分块上限测试不足以排除错误默认值、benchmark reorder 测试依赖调度时序等证据边界缺陷,并逐一收敛为可独立证明运行时事实的 oracle。读完本文,你将掌握一套可复用的测试治理方法论——包括"负向断言必须作用在运行时组装结果上"、"用事件同步替代固定睡眠"、"用精确计数替代模糊上限"三条核心原则,以及 Backend 与 Web 两侧测试套件的取舍标准与验证基线,可直接用于你自己的测试套件审计。
背景:测试套件精简后的独立审查发现了什么
Yuxi 的测试套件此前已经过一次"精简",但在精简后的独立审查中,审计者仍发现若干证据边界不完整的问题,集中在四类场景:
| 问题 | 具体表现 | 后果 |
|---|---|---|
| Prompt 测试不完整 | 只检查源字符串,没有验证实际组装结果 | 无法证明运行时 prompt 中不存在重复注入 |
| 默认分块上限测试不足 | 只断言"硬上限",不校验默认值 | 1.5 倍硬上限允许错误的默认分块值通过 |
| benchmark reorder 测试依赖调度时序 | 依赖固定延迟和调用计数 | 事件循环调度变化会制造假失败或假绿 |
| 决策记录引用失效文件 | 引用了已删除的测试文件 | 文档与仓库状态脱节,误导后续读者 |
此外,审计还立下两条硬性纪律:不以测试名称相似或文件较长作为删除理由;源码检查尚无等价行为覆盖时保留,并明确它只证明源码结构,不能冒充浏览器行为证据。
该决策记录的类型被标记为simplification,Owner 指向backend/test/unit/agents/test_chatbot_prompt.py,说明这是一次"以简驭繁"的收敛——不是删减覆盖,而是把每条断言都钉在真正的观察边界上。
核心决策概览
针对上述问题,审计作出三项关键收敛决策:
- 在运行时组装后的 chatbot prompt 上保留
html:preview不被重复注入的负向断言; - benchmark worker 用事件同步异常与后续调用,确保 reorder buffer 的测试不依赖固定睡眠;
- 默认 512 token 配置使用精确 token 计数作为 oracle,并修正决策记录中已删除测试文件的合并路径。
每一条决策都对应一个被明确记录并否决的替代方案(详见下文各节),这正是 ADR 文档"决策可回溯、理由可审计"的体现。
决策一:Prompt 负向断言必须作用在运行时组装结果上
问题:只检查源字符串无法证明运行时不重复注入
旧的 Prompt 测试只检查prompt.py中定义的源字符串常量。审计判定该做法不可接受:源码中不存在html:preview与运行时组装后的 prompt 中不存在html:preview是两回事——组装逻辑(如拼接文件系统约束、日期、用户 system prompt)可能引入重复注入。
决策:对build_prompt_with_context的真实输出做负向断言
收敛后的测试位于 test_chatbot_prompt.py,其核心用例为:
from types import SimpleNamespace from yuxi.agents.buildin.chatbot.prompt import build_prompt_with_context def test_chatbot_prompt_declares_workspace_visibility_and_default_write_boundary(): prompt = build_prompt_with_context( SimpleNamespace( workdir_path="/home/gem/user-data/projects/11111111-1111-4111-8111-111111111111", system_prompt="", ) ) assert "可以读取其他 Project 目录作为参考" in prompt assert "未经用户明确要求,不得在当前 Project Workdir 之外" in prompt assert "/home/gem/user-data/agents/skills/" in prompt assert "html:preview" not in prompt它调用真实的 build_prompt_with_context 函数,传入带workdir_path和system_prompt的上下文对象,对组装结果同时做正向断言(工作区可见性、默认写边界、个人 Skill 目录)和负向断言(html:preview不重复注入)。
从 prompt.py 的实现可以看到组装过程:build_prompt_with_context会拼接当前日期、基础 PROMPT、动态生成的<| 文件系统约束 |>段落以及用户的system_prompt,且当 context 缺少workdir_path时会抛出ValueError。这解释了为什么测试必须构造带workdir_path的上下文——它是该函数正常运行的前置条件。
替代方案:只检查源字符串(被拒绝)
文档明确记录:"只检查源字符串:拒绝,因为它不能证明实际运行时 prompt 没有重复注入。"这一原则可推广为一条通用的 oracle 选择准则:凡是断言"X 不发生",都应尽量放在最终产物上检查,而不是检查产生产物的中间常量。
决策二:benchmark worker 用事件同步替代固定睡眠
问题:reorder buffer 测试依赖调度时序
Yuxi 的 benchmark generation 采用多 worker 并发 + reorder buffer 按attempt_no连续 yield 的流式架构(见 benchmark_generation 相关实现)。旧的测试用固定延迟 + 调用计数来模拟"某 worker 先于另一 worker 产出"的时序,而事件循环的调度顺序一旦变化,就会出现假失败(该同步的没同步上)或假绿(该暴露的竞态没暴露)。
决策:用asyncio.Event显式同步
收敛后的测试位于 test_benchmark_generation.py,全部改用asyncio.Event做确定性同步。以异常收敛测试为例(第 403 行起):
@pytest.mark.asyncio async def test_iter_generated_benchmark_items_drains_reorder_buffer_on_exception(monkeypatch): """多 worker 下某 worker 抛异常时,reorder 缓冲中已产出但未按 attempt_no 连续 yield 的 item 经 drain 路径保全。""" monkeypatch.setattr(benchmark_generation, "select_model", lambda model_spec: TrackingLlm()) monkeypatch.setattr(benchmark_generation, "kb_manager", NoQueryKnowledgeBase()) call_count = 0 first_call_started = asyncio.Event() release_first_call = asyncio.Event() async def fake_generate(**kwargs): nonlocal call_count call_count += 1 if call_count == 1: first_call_started.set() await release_first_call.wait() # 阻塞第一个调用,模拟延迟后抛异常 raise RuntimeError("worker error") ...测试通过first_call_started.set()标记第一个 worker 已进入调用、release_first_call控制何时放行,从而精确构造"第一个 attempt 永不产出、其他 worker 的 item 卡在 reorder 缓冲"的时序,并断言异常 break 后 drain 路径把这些乱序 item 一并 yield:
assert items == [ {"query": "q2", "gold_answer": "a", "gold_chunk_ids": ["anchor_chunk"]}, {"query": "q3", "gold_answer": "a", "gold_chunk_ids": ["anchor_chunk"]}, {"query": "q4", "gold_answer": "a", "gold_chunk_ids": ["anchor_chunk"]}, ]配套用例还验证了:
- 真流式:第 2 次 LLM 调用未完成前第 1 条已产出(
test_iter_generated_benchmark_items_yields_before_all_workers_finish),并配合asyncio.wait_for(collect_items(), timeout=5)防止死锁挂死; - 异常时取消其他 worker:
test_iter_generated_benchmark_items_cancels_other_workers_on_exception断言第二个 worker 收到CancelledError(second_worker_cancelled.is_set())。
替代方案:保留固定延迟和调用计数(被拒绝)
文档明确记录:"保留固定延迟和调用计数:拒绝,因为事件循环调度变化会制造假失败或假绿。"事件同步的优点在于:无论调度器如何切换,测试只依赖"事件已设置"这一语义事实,而不是"过了多少毫秒"。
决策三:默认 512 token 上限用精确计数 oracle
问题:只断言硬上限会让错误默认值漏网
general parser 的分块流程存在两层上限:默认目标上限(512 token)与可选硬上限(hard_limit_token_num)。旧的测试只断言"任意 chunk 不超过硬上限",但若实现把默认值错误放宽(例如从 512 改成 1024),1.5 倍硬上限(768)依然成立,错误的默认值就会通过测试。
决策:锁定默认值的精确 token 计数
收敛后的 test_chunking_token_limit.py 新增了专门锁定默认值的用例:
def test_default_config_uses_512(self): doc = "测试\n" * 400 # 约 800 token;默认值错误放宽到 1024 时不会切分 chunks = general.chunk_markdown(doc) token_counts = [nlp.count_tokens(chunk) for chunk in chunks] assert token_counts == [514, 286]该用例的巧妙之处在于:"测试\n" * 400约 800 token,若默认上限被错误放宽到 1024 则根本不会切分,测试即失败;同时精确断言切分结果是[514, 286],把默认值语义本身变成 oracle。
该文件还通过_isolated_modulesfixture 用sys.modules占位隔离加载nlp.py与parsers/general.py,绕开 yuxi 包的重依赖链(langchain / pydantic / .env),并在运行后清理sys.modules避免污染其他测试——这同时印证了文档中"unit 测试仍可在不修改sys.path的情况下收集"的验证结论。同文件内的TestHardSplitByTokenLimit还覆盖了hard_limit_token_num=768时"短尾合并"的精确行为([512, 758])与max_chars=0时下限取 1 的边界(["a", "b", "c"])。
替代方案:只断言硬上限(被拒绝)
文档明确记录:"只断言硬上限:拒绝,因为 1.5 倍硬上限允许错误的默认分块值通过。"这条决策同时适用于语义分块(semantic parser):合并后的 test_semantic_chunking.py 中,test_empty_heading_preserves_current_title_context显式传入parser_config={"chunk_token_num": 512}验证空标题行不丢失标题上下文,test_truncated_heading_token_stream_is_ignored用 monkeypatch 制造截断的 heading token 流验证空结果。
决策四:Web 测试按实际观察边界取舍
Web 侧的审计遵循同一哲学——每条测试必须落在它真正能观察到的边界上:
- 保留:请求、状态、协议、解析和渲染结果的测试。例如"共享详情框架的运行时测试检查具名面板内容实际渲染、删除 Tab 后对应内容消失;在内存中移除动态内容插槽的负控会因面板内容缺失而失败",即通过运行时模板行为验证插槽、动态 Tab 与
forceRender透传;HTML 预览尺寸仍在渲染输出上检查;工具参数解析保留实际输入结果测试。 - 删除:仅冻结 CSS 数值、图标、旧文案和搬迁路径的检查——这些属于一次性迁移的产物,删除它们不代表视觉回归已有自动覆盖,文档对此有清醒声明。
- 混合测试只删装饰性断言:保留可访问性、路由、数据隔离与迟到响应检查。
- 源码检查策略:尚无等价行为覆盖时保留,但明确其边界——只证明源码结构,不能冒充浏览器行为证据,暂留的源码结构检查不得作为 UI 行为的证据。
文档还记录了 Web 守卫测试的收敛细节:守卫测试同时覆盖正常放行与拒绝;配置合并读取检查具体返回值和 Store 状态;模型优先级源码检查先确认表达式存在;轮询生命周期检查请求完成后再次调度及停止后的清理。且测试入口仍使用web/package.json的全目录 selector,未跳过或排除测试——避免用"配置文件排除"这种隐式手段掩盖覆盖缺口。
决策五:Backend 测试保留独立观察面与 QA parser 合并
保留原则:观察面不因文件名而删
Backend 侧确立的保留原则是:业务、权限、事务、队列、文件隔离、恢复和部署边界的独立观察面全部保留。文档特别强调:
live_api_cleanup(见 test_live_api_cleanup.py 与 test_live_api_cleanup_run_rows.py)、性能工具、Compose 契约(test_docker_compose_service_boundaries.py)和惰性导入检查均有明确 consumer;- 不因文件名含 cleanup/tmp/source 或单文件用例少而删除——这是对"以名取人"式审计的明确否定。
合并:两个 QA parser 测试收敛为一个文件
两个 QA parser 测试合并为 test_qa_parser.py,共享一次隔离加载(_load_qa_parser通过importlib.util.spec_from_file_location按文件路径隔离加载ragflow_like/parsers/qa.py),并完整保留全部 oracle:
| 能力面 | 对应测试类/用例 |
|---|---|
| 结构化提取(Q:/A: 前缀、标题路径) | TestSplitLongQaChunks、TestAtxHeadingBoundary |
| 围栏边界(~~~ / ``` 内不拆出虚构问答) | TestPrefixFenceBoundary(含fence_with_info_string、不匹配围栏、未闭合围栏吸收等) |
| 孤儿答案归属 | TestOrphanAnswer |
| 超长切分限长 | TestChunkMarkdownLengthCap(走实现默认常量锁定"默认上限不超 embedding 承诺") |
| 默认配置语义 | max_chars显式传参 + 默认路径双覆盖 |
其中_EMBEDDING_CHAR_LIMIT = 4000是独立于实现常量_QA_CHUNK_MAX_CHARS的保守字符兜底(对应 bge_m3 的 4096 token 上下文上限),避免自我引用 oracle——测试的期望值不应直接拷贝实现常量。TestSplitLongQaChunks还覆盖了含 tab 的问题保持完整、非英语答案标记不作分隔符、无答案标记的 tab 文本硬切等边界。
文档强调:没有合并观察边界不同的 unit、integration 和 E2E 测试——合并只发生在同一观察边界的文件之间。
第二轮收敛:死辅助对象清理与附件服务更名
决策记录还记录了第二轮收敛的三项动作:
- 删除 3 个同文件内无消费者的死辅助对象:静态引用检查确认
FakeKnowledgeBase、_ChildContext、_make_thread_files在仓库内无消费者后才删除; - 真实附件服务测试更名:从
test_tmp_attachment_service.py更名为 test_attachment_service.py,消除"临时脚本"的错误信号,更名后仍收集相同的 16 个用例; - 不删除仍被外部清理入口调用的兼容函数;队列策略正向测试改为断言规范化返回值(覆盖
enqueue/reject/steer三个有效值的具体返回结果),而不是只验证不抛异常;两个 unit 测试移除无效的 cwdsys.path注入;示例问题测试的 3 个重复FakeKnowledgeBase定义收敛为 1 个按详情注入的 fake,异常和成功分支的返回断言保持不变。
此外,后端测试目录的静态审计确认6 个数据 fixture 均被解析或真实知识库路由使用,其中测试图片.png作为测试文档.docx的嵌入资源由转换结果间接校验(见 backend/test/data 目录),未删除仍被真实路由或解析测试使用的 fixture。
验证结果与发布门禁基线
收敛完成后,验证基线如下:
| 验证项 | 结果 |
|---|---|
| Prompt、分块和 benchmark generation 相关单元测试 | 36 passed |
ruff check与改动文件ruff format --check | 通过 |
已删除的test_semantic_chunking_empty_heading.py引用 | 改为合并后的 test_semantic_chunking.py |
| Web 守卫测试 | 同时覆盖正常放行与拒绝 |
| 配置合并读取 | 检查具体返回值和 Store 状态 |
| 模型优先级源码检查 | 先确认表达式存在 |
| 轮询生命周期 | 请求完成后再次调度及停止后的清理 |
| unit 测试收集 | 无需修改sys.path即可收集 |
后果、旧能力禁令与重新引入条件
后果
- 测试继续覆盖真实组装结果、worker 异常收敛和默认配置语义;
- 精确分块 oracle 依赖当前 tokenizer 的确定性结果,tokenizer 或分块策略变更时必须同步审阅语义 Owner 和决策记录;
- Web 的样式数值与一次性迁移不再由源码正则冻结,但这些删除不代表视觉回归已有自动覆盖;
- 队列、流式、API、文件隔离和设置交互测试继续独立维护;暂留的源码结构检查不能冒充浏览器行为证据。
旧能力禁令
文档用一句话立下红线:"测试不得退回只检查源常量、固定睡眠调度或模糊长度上限的证据。"这三个"退回"场景恰好对应本次收敛的三大决策,可作为任何测试评审的快速自检清单。
重新引入条件
文档规定,只有满足以下全部条件,才可替换当前 oracle:
- 新的测试仍能独立证明同一运行时事实;
- 对调度有显式同步;
- 能区分配置回归。
方法论提炼:可复用的测试审计清单
综合全文,可将本次收敛提炼为六条可迁移到其他项目的审计准则:
- 负向断言对准最终产物:断言"X 不发生"时,检查运行时组装/渲染后的结果,而非源常量;
- 用事件同步替代睡眠:所有并发时序测试使用
asyncio.Event(或等价同步原语),并配合asyncio.wait_for超时防挂死; - 默认值本身要成为 oracle:用精确计数断言默认配置的产物形态,防止错误默认值在硬上限掩护下漏网;
- 期望常量独立于实现常量:测试中的上限兜底值应来自独立推导(如模型上下文预算),避免自我引用;
- 按观察边界取舍测试:保留请求/状态/协议/渲染结果等真实观察边界,删除只冻结迁移产物的断言;源码结构检查可保留但必须声明其证据边界;
- 不以文件名或文件大小作删除依据:有明确 consumer 的测试(cleanup、性能、Compose 契约、惰性导入)必须保留;合并测试只发生在同一观察边界内,且不得改变 import 隔离与 oracle 语义。
【免费下载链接】Yuxi可私有部署的多租户知识智能体平台:统一 RAG、知识图谱、多智能体、MCP/Skills、沙盒与权限管理。Self-hosted knowledge agent platform for RAG, knowledge graphs and multi-agent workflows.项目地址: https://gitcode.com/GitHub_Trending/yu/Yuxi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考