Skip to content

Feature: 新增 trpc_agent_bench 压测套件 - #366

Merged
raychen911 merged 1 commit into
mainfrom
feature/add_benchmark_utils
Nov 4, 2026
Merged

raychen911 merged 1 commit into
mainfrom
feature/add_benchmark_utils

Conversation

@weimch

@weimch weimch commented Nov 2, 2026

Copy link
Copy Markdown
Contributor
  • 参考Agno,新增压测套件及对应单测;用于测框架自身开销:import、构造、单轮 run、多轮会话、带工具的 Agent 执行和内存占用;Agent执行不会走实际的LLM调用,而是走一个Mock的llm-server
  • 设计文档:docs/design/01-bench-design.md
  • 使用文档:docs/mkdocs/zh/bench.md
  • 补充llmagent的基础压测数据

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围:594bfb09..ca09227c(新增 trpc_agent_bench 压测套件,51 个文件 / +5881 行,含 13 个测试文件)。计划符合性:run_bench 编排、MockOpenAIServer、PerformanceEval、12 个内置 benchmark、报告生成与 bench/llmagent 适配均按设计文档实现,mock 协议经逐行追踪与 SDK 真实 wire 格式(_format_messages 将工具结果序列化为 role="tool",request_position 的 turn/step 与每条请求的用户/助手消息计数严格对应)完全吻合,turnN/loopN 脚本与 mock 回复一一配对,无索引错位。并发 user_id 隔离、每 (target, benchmark) 独立子进程、tracemalloc 分 pass 测量、统计口径等核心设计均正确。主要风险为三处中等影响问题:内置 benchmark 在 handle 构造失败时跳过 teardown 导致 mock server/session 泄漏(_session_bench._run_turns、tools.py、tools_parallel.py);ensure_turn_completed 完成断言可用 partial-only 流绕过、工具成功断言不校验 function_response.id 与 function_call.id 对应(_eval.py:229-245);import_time 子进程无超时,import 挂起会无界阻塞整个压测。另有低危问题:run_bench._collect 收集既有旧结果污染报告、tracemalloc 异常路径不 stop()。测试充分性:13 个测试文件覆盖 mock 协议、统计、装配、报告与编排,但未覆盖上述边界(handle 失败、partial-only 流、子进程挂起、异常路径清理)。门禁结论:未发现会破坏核心 benchmark 正确性或数据完整性的高危缺陷,无 SEVERE 评论,state 为 PASSED。

发现的问题

中等

trpc_agent_bench/bench/_builtin/_utils/_session_bench.py:80-85

问题: _run_turns 中 box = await setup(script) 成功启动 mock server 并完成 prepare() 之后,handle = await box.target.handle(...) 位于 try/finally 之外;tools.py:46-47 与 tools_parallel.py:56-57 也是同样模式。handle() 内部会执行 instantiate()(见 bench/llmagent/base.py:132-139),一旦构造 agent 抛异常,finally 中的 teardown(box) 不会执行。

触发条件: LlmAgentTarget.handle() 或任意自定义 target 的 handle()/instantiate() 抛异常时(如工具工厂失败、模型配置错误、agent 构造校验失败)。setup() 自身的异常路径(_setup.py:46-51)已正确关闭 server,但这里不在该保护范围内。

实际影响: mock OpenAI server 进程与 session service、agent runtime 不释放——server 是 daemon 进程,benchmark 子进程退出时会被回收,但进程存活期间的资源(内存、连接)持续占用;且错误信息中没有任何“服务未清理”的提示,难以排查。

修正方向: 将 handle 创建移入 try 内(box = await setup(script); try: handle = await ...; ... finally: await teardown(box)),保证任何构造失败都走 teardown 关闭 server 与 session service。

中等

trpc_agent_bench/bench/_eval.py:229-233

问题: ensure_turn_completed 的完成判定(_eval.py:229-233)在“没有 final 事件”时依赖 partial 文本兜底:if not turn_complete and not has_non_partial_content and not partial: raise。只有 partial 事件、没有任何 is_final_response()/turn_complete 事件时不会报“turn did not complete”,且 final_text 回退到 "".join(partial)。mock 的 _sse_stream 总是有最终非 partial 事件,但流被截断(连接中断、chunk 缺失)时可能只产生 partial 事件。

触发条件: mock 流式响应在 final 事件前中断(如客户端断连、_sse_stream 中途异常、chunk_delay_ms 非零时的时序问题),或自定义 benchmark 手动构造仅含 partial 事件的流。

实际影响: 本应失败(turn 未完成)的一次 benchmark iteration 被当作成功计入统计,expected_text 检查也可能被部分文本偶然匹配而漏报,压测结果把“错误路径当成了正常结果”,与设计文档声称的“确定性 + 正确性断言”目标相悖。

修正方向: 将“缺失 final 事件”视为失败——当事件列表非空且没有任何 is_final_response()/非 partial 内容事件时直接 raise,而不是用 partial 文本兜底;final_text 只取非 partial 事件文本。

中等

trpc_agent_bench/bench/_eval.py:238-245

问题: _eval.py:238-245 的 expect_tool_success 只检查“存在 call 且存在 result、result 的 response dict 无 error 键”,没有把每个 function_response.id 与对应的 function_call.id 关联校验;tools_parallel 的“3 个响应”计数(tools_parallel.py:72-75)也只数部分数量。若某个工具失败只产生 function_response 缺失或 id 不匹配的事件(如 tool_not_found 错误事件带非 partial 内容),该 turn 会被判定为工具执行成功。

触发条件: 工具执行失败但以非 error 事件呈现(例如 _tools_processor.py:752-768 生成的错误事件带 function_response 但 error_code 键在 response.error 之外的变体)、或工具未执行但 ensure_turn_completed 已被 partial 文本兜底放过。

实际影响: tools、tools_parallel、loop5/loop10 等工具类 benchmark 可能在工具循环实际失败时仍通过断言并产出“成功”的耗时/内存样本,压测数据被静默污染。

修正方向: 逐个断言每个 call 都能在 results 中找到 id 相同的 function_response,并统一通过 event.is_error()/error_code 判定工具失败,而不是只检查 response dict 的 error 键。

中等

trpc_agent_bench/bench/_builtin/import_time.py:20-22

问题: import_time._run(import_time.py:20-26)的 subprocess.run 没有 timeout;main() 对每个 sample 启动 python -c "import <target>",共 15+15 次子进程。若被测包 import 时挂起(网络请求、交互提示、死锁、或冷启动时较慢),整个 benchmark 会无限阻塞。

触发条件: 目标 import_module 的 import 副作用耗时超长或永久挂起(自定义 target 只约束了导出 TARGET,import 内容无限制)。

实际影响: run_bench 的该子进程永不退出,subprocess.run(check=True) 一直等待,整个压测 run 无界挂起,且无任何超时信息可排查;这是工具套件最恶劣的失败模式之一。

修正方向: 给 subprocess.run 增加 timeout=(如 60s 或可配置),捕获 TimeoutExpired 后 raise 明确错误并结束该 benchmark;同时可将超时值写入 BENCH_* 环境变量便于自定义。

较低

trpc_agent_bench/run_bench.py:111

问题: run_bench._collect(run_bench.py:104-118)直接 glob 目标目录下所有 *.json 并把它们全部写进 summary.json,build_report 同样收集全部既有 JSON。两次运行间残留的旧结果(包括更早的 loop10_c2.json、memory_per_agent.json 或用户手动放置的任意 JSON)会被当作本次运行的结果收集进报告。

触发条件: 在未清空 results/<target>/ 的情况下重复运行 run_bench(设计文档明确支持“同一套脚本可重复跑”),或前半段 benchmark 失败、run_bench 异常退出,旧文件与新文件混合。

实际影响: 报告中出现过期/无对应 run_bench 配置的结果行(如同一 name 多 target 的错乱行、缺 baseline 的孤立行),对比表数值失真,压测结论被静默污染。

修正方向: 每次运行开始时清理 results_root 下本次 targets 的目录(或为每次 run 生成独立子目录/带 run-id 的目录),_collect 只收集本次写入的文件。

较低

trpc_agent_bench/bench/_eval.py:176-184

问题: _eval.py:171-177(_memory_sync)与 _eval.py:181-186(_memory_async)中,tracemalloc.start() 与 tracemalloc.stop() 之间调用 self.func()/await self.func(),且 tracemalloc.stop() 不在 finally 中;memory_footprint._per_agent_footprint(memory_footprint.py:27-32)同样如此。tracemalloc.start() 是幂等的(重复调用无报错),一旦 func 抛异常,stop() 被跳过,tracing 保持开启状态。

触发条件: 任意一次 benchmark iteration 的 func 抛异常(工具执行失败、ensure_turn_completed 断言失败、网络错误等)。

实际影响: 该进程后续所有 tracemalloc.get_traced_memory() 结果继续叠加此前泄漏的分配,且若进程未立即退出(如测试或长驻 runner 中复用 PerformanceEval),后续 memory pass 的数值被污染;即使进程随即退出,也没有任何显式清理提示。

修正方向: 将 tracemalloc.stop() 放入 finally 块(或在异常路径显式 stop()),确保任何情况下 tracing 都被关闭后再向上抛异常。

Comment on lines +80 to +85
box: BenchSetup = await setup(script)
handle = await box.target.handle(
box.prepared,
tools_factory=tools_factory or (lambda: []),
)
try:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _run_turns 中 box = await setup(script) 成功启动 mock server 并完成 prepare() 之后,handle = await box.target.handle(...) 位于 try/finally 之外;tools.py:46-47 与 tools_parallel.py:56-57 也是同样模式。handle() 内部会执行 instantiate()(见 bench/llmagent/base.py:132-139),一旦构造 agent 抛异常,finally 中的 teardown(box) 不会执行。

触发条件: LlmAgentTarget.handle() 或任意自定义 target 的 handle()/instantiate() 抛异常时(如工具工厂失败、模型配置错误、agent 构造校验失败)。setup() 自身的异常路径(_setup.py:46-51)已正确关闭 server,但这里不在该保护范围内。

实际影响: mock OpenAI server 进程与 session service、agent runtime 不释放——server 是 daemon 进程,benchmark 子进程退出时会被回收,但进程存活期间的资源(内存、连接)持续占用;且错误信息中没有任何“服务未清理”的提示,难以排查。

修正方向: 将 handle 创建移入 try 内(box = await setup(script); try: handle = await ...; ... finally: await teardown(box)),保证任何构造失败都走 teardown 关闭 server 与 session service。

Comment on lines +229 to +233
turn_complete = any(
getattr(event, "turn_complete", False) or (
callable(getattr(event, "is_final_response", None)) and event.is_final_response()) for event in event_list)
if not turn_complete and not has_non_partial_content and not partial:
raise RuntimeError("turn did not complete")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: ensure_turn_completed 的完成判定(_eval.py:229-233)在“没有 final 事件”时依赖 partial 文本兜底:if not turn_complete and not has_non_partial_content and not partial: raise。只有 partial 事件、没有任何 is_final_response()/turn_complete 事件时不会报“turn did not complete”,且 final_text 回退到 "".join(partial)。mock 的 _sse_stream 总是有最终非 partial 事件,但流被截断(连接中断、chunk 缺失)时可能只产生 partial 事件。

触发条件: mock 流式响应在 final 事件前中断(如客户端断连、_sse_stream 中途异常、chunk_delay_ms 非零时的时序问题),或自定义 benchmark 手动构造仅含 partial 事件的流。

实际影响: 本应失败(turn 未完成)的一次 benchmark iteration 被当作成功计入统计,expected_text 检查也可能被部分文本偶然匹配而漏报,压测结果把“错误路径当成了正常结果”,与设计文档声称的“确定性 + 正确性断言”目标相悖。

修正方向: 将“缺失 final 事件”视为失败——当事件列表非空且没有任何 is_final_response()/非 partial 内容事件时直接 raise,而不是用 partial 文本兜底;final_text 只取非 partial 事件文本。

Comment on lines +238 to +245

if expect_tool_success:
if not calls or not results:
raise RuntimeError("tool loop did not execute")
for response in results:
payload = getattr(response, "response", None)
if isinstance(payload, dict) and payload.get("error"):
raise RuntimeError("tool call failed")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _eval.py:238-245 的 expect_tool_success 只检查“存在 call 且存在 result、result 的 response dict 无 error 键”,没有把每个 function_response.id 与对应的 function_call.id 关联校验;tools_parallel 的“3 个响应”计数(tools_parallel.py:72-75)也只数部分数量。若某个工具失败只产生 function_response 缺失或 id 不匹配的事件(如 tool_not_found 错误事件带非 partial 内容),该 turn 会被判定为工具执行成功。

触发条件: 工具执行失败但以非 error 事件呈现(例如 _tools_processor.py:752-768 生成的错误事件带 function_response 但 error_code 键在 response.error 之外的变体)、或工具未执行但 ensure_turn_completed 已被 partial 文本兜底放过。

实际影响: tools、tools_parallel、loop5/loop10 等工具类 benchmark 可能在工具循环实际失败时仍通过断言并产出“成功”的耗时/内存样本,压测数据被静默污染。

修正方向: 逐个断言每个 call 都能在 results 中找到 id 相同的 function_response,并统一通过 event.is_error()/error_code 判定工具失败,而不是只检查 response dict 的 error 键。

Comment on lines +20 to +22
def _run(command: list[str]) -> float:
started = time.perf_counter()
completed = subprocess.run(command, capture_output=True, text=True, check=False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: import_time._run(import_time.py:20-26)的 subprocess.run 没有 timeout;main() 对每个 sample 启动 python -c "import <target>",共 15+15 次子进程。若被测包 import 时挂起(网络请求、交互提示、死锁、或冷启动时较慢),整个 benchmark 会无限阻塞。

触发条件: 目标 import_module 的 import 副作用耗时超长或永久挂起(自定义 target 只约束了导出 TARGET,import 内容无限制)。

实际影响: run_bench 的该子进程永不退出,subprocess.run(check=True) 一直等待,整个压测 run 无界挂起,且无任何超时信息可排查;这是工具套件最恶劣的失败模式之一。

修正方向: 给 subprocess.run 增加 timeout=(如 60s 或可配置),捕获 TimeoutExpired 后 raise 明确错误并结束该 benchmark;同时可将超时值写入 BENCH_* 环境变量便于自定义。

) -> dict[str, Any]:
results: list[dict[str, Any]] = []
for target in targets:
for path in sorted((results_root / target).glob("*.json")):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: run_bench._collect(run_bench.py:104-118)直接 glob 目标目录下所有 *.json 并把它们全部写进 summary.json,build_report 同样收集全部既有 JSON。两次运行间残留的旧结果(包括更早的 loop10_c2.json、memory_per_agent.json 或用户手动放置的任意 JSON)会被当作本次运行的结果收集进报告。

触发条件: 在未清空 results/<target>/ 的情况下重复运行 run_bench(设计文档明确支持“同一套脚本可重复跑”),或前半段 benchmark 失败、run_bench 异常退出,旧文件与新文件混合。

实际影响: 报告中出现过期/无对应 run_bench 配置的结果行(如同一 name 多 target 的错乱行、缺 baseline 的孤立行),对比表数值失真,压测结论被静默污染。

修正方向: 每次运行开始时清理 results_root 下本次 targets 的目录(或为每次 run 生成独立子目录/带 run-id 的目录),_collect 只收集本次写入的文件。

Comment on lines +176 to +184
tracemalloc.stop()
return max(0.0, peak - baseline) / (1024.0 * 1024.0)

async def _memory_async(self, baseline: float) -> float:
gc.collect()
tracemalloc.start()
await self.func()
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _eval.py:171-177(_memory_sync)与 _eval.py:181-186(_memory_async)中,tracemalloc.start() 与 tracemalloc.stop() 之间调用 self.func()/await self.func(),且 tracemalloc.stop() 不在 finally 中;memory_footprint._per_agent_footprint(memory_footprint.py:27-32)同样如此。tracemalloc.start() 是幂等的(重复调用无报错),一旦 func 抛异常,stop() 被跳过,tracing 保持开启状态。

触发条件: 任意一次 benchmark iteration 的 func 抛异常(工具执行失败、ensure_turn_completed 断言失败、网络错误等)。

实际影响: 该进程后续所有 tracemalloc.get_traced_memory() 结果继续叠加此前泄漏的分配,且若进程未立即退出(如测试或长驻 runner 中复用 PerformanceEval),后续 memory pass 的数值被污染;即使进程随即退出,也没有任何显式清理提示。

修正方向: 将 tracemalloc.stop() 放入 finally 块(或在异常路径显式 stop()),确保任何情况下 tracing 都被关闭后再向上抛异常。

@weimch
weimch force-pushed the feature/add_benchmark_utils branch from ca09227 to 1b4c7b4 Compare November 3, 2026 02:36
- 参考Agno,新增压测套件及对应单测;用于测框架自身开销:import、构造、单轮 run、多轮会话、带工具的 Agent 执行和内存占用;Agent执行不会走实际的LLM调用,而是走一个Mock的llm-server
- 设计文档:docs/design/01-bench-design.md
- 使用文档:docs/mkdocs/zh/bench.md
- 补充llmagent的基础压测数据
@weimch
weimch force-pushed the feature/add_benchmark_utils branch from 1b4c7b4 to 6ea849d Compare November 3, 2026 02:49
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围:base 594bfb0..head 1b4c7b4(52 个文件,+6378/-1),即新增 trpc_agent_bench 压测套件。已完整阅读 run_bench 子进程编排、MockOpenAIServer(fastapi/uvicorn 独立进程)、_target/_setup/_profile 协议层、_eval 测量与断言、_report 报告生成、12 个内置 benchmark、bench/llmagent 目标适配及其与 trpc_agent_sdk 的 Runner/OpenAIModel/LlmAgent/InMemorySessionService/事件流(partial/turn_complete/is_final_response)等调用链,并核对设计文档与 13 个测试文件。计划符合性:套件结构、内置压测项、mock 注入、进程隔离、统计口径(median/p95、时间内存分开测)、报告与文档均符合设计;pyproject 将 trpc_agent_bench 纳入 wheel 且 bench extra 依赖 fastapi 成立(uvicorn 在核心依赖中)。主要风险:并发 _cN 结果在报告中的分组失效(MODERATE)、跨运行旧结果文件混入报告(MODERATE)、少量环境变量与平台兼容性问题(LOW)。测试充分性:13 个测试覆盖了统计、并发展开、skip/iterations 校验、report 表格形态和 mock wire 格式,但未覆盖并发后缀在报告中的分组、跨运行结果文件陈旧这两个场景。门禁结论:未发现高置信的严重正确性/安全/数据损坏缺陷,报告 MODERATE/LOW 问题 4 项。

发现的问题

中等

trpc_agent_bench/bench/_report.py:147-158

问题: _report._name_key(_report.py 147-158 行)对带并发后缀的结果名分组失效:turn1_c8、turn5_c8 这类名字的 suffix 是 _c8,suffix[0] 为 _ 而不是数字,命中 suffix and suffix[0].isdigit() 的分支不会成立,最终落入默认 rank 7("自定义 benchmark")。

触发条件: 使用文档/README 主推的 --concurrency 1,8,16,32 运行 turn1/turn5/turn10、loop1/loop5/loop10 后生成报告,表中会同时出现基础名结果和 _cN 后缀结果。

实际影响: turn1_c8、loop5_c16 等并发结果被排在表格末尾的自定义区段,而不是紧挨对应的 turn1/loop5 行;报告 Notes 承诺的 "turn1/turn5/turn10 和 loop1/loop5/loop10 分别放在一起" 被破坏,并发对比表难以对照阅读,压测主场景的报告排版与实际不一致。

修正方向: 在 _name_key 中先将 _c<数字> 后缀剥离后再做前缀匹配(例如对 name 先 re.sub(r'_c\d+$', '', name) 再按现有逻辑分组),或对 turn/loop 前缀额外接受 _cN 形式的后缀,使并发变体与基础项同组排序。

中等

trpc_agent_bench/run_bench.py:104-118

问题: run_bench 每次运行前不清理 results_root/<target>/ 目录,而 _collect(run_bench.py 104-118 行)与 build_report 的 _load(_report.py 86-97 行)都以 glob("*.json") 全量读取目标目录下所有结果文件。

触发条件: 在同一 --results-dir 下先跑完整 12 项或 --concurrency 1,8,之后再跑只含 turn1 或不同并发集合的运行,旧运行遗留的 turn5.json、turn10_c8.json 等文件不会被删除。

实际影响: summary.json、report.md、report.html 会把上一次运行(可能对应不同代码版本或不同并发配置)的结果当作本次运行数据混合展示,甚至出现同一 benchmark 新旧两行、比较表基线错配,压测数字失真且难以察觉。

修正方向: 每次运行开始时按 target 清空 results_root/<target>(或先记录本次将要写入的结果名,收集/出报告时只纳入本次生成的文件),并在写 summary.json 时标注运行时间供核对。

较低

trpc_agent_bench/bench/_builtin/memory_footprint.py:21

问题: memory_footprint.py 第 21 行直接 int(os.environ.get("BENCH_MEMORY_AGENTS", "1000")),环境变量值未做类型校验;该变量在文档/README 中也没有任何说明。

触发条件: 用户(或 CI 环境)设置了非数字的 BENCH_MEMORY_AGENTS(例如空字符串、auto),运行 memory_footprint 压测项时模块导入阶段即抛 ValueError。

实际影响: 压测子进程在 _runner 加载模块时直接崩溃,run_bench 因 check=True 整体失败退出,且报错只是裸的 int() 异常,用户无法判断是哪个配置出了问题。

修正方向: 用与 _positive_int/_parse_concurrency 一致的校验方式解析该变量,解析失败时给出明确错误信息(或回退默认值并告警),并在文档中登记该环境变量的含义。

较低

trpc_agent_bench/bench/_model.py:356

问题: MockOpenAIServer.start()(_model.py 355-375 行)使用 multiprocessing.get_context("fork") 硬编码 fork 启动方式。

触发条件: 在 Windows 上运行压测套件(fork 上下文不存在,get_context("fork") 直接抛 ValueError);在 macOS 上从已导入 fastapi/openai 等线程化库的进程中 fork,存在子进程挂起风险。

实际影响: 套件被打包为跨平台 wheel(pyproject 支持 3.10-3.12,无平台限制),但在 Windows 上整套压测无法运行;macOS 上 mock server 可能无法在 20 秒内上报端口,start() 抛 "mock OpenAI server did not report a port"。

修正方向: 通过 multiprocessing.get_context("spawn")(或默认上下文)启动子进程,并把 spec/model_conf 以参数或序列化方式传入,避免依赖 fork 语义。

Comment on lines +147 to +158
def _name_key(name: str) -> tuple[int, int, list[Any]]:
for prefix, rank in _ORDER:
if prefix in ("turn", "loop"):
if not name.startswith(prefix):
continue
suffix = name[len(prefix):]
if suffix and suffix[0].isdigit():
return rank, 0, _natural_key(name)
continue
if name == prefix or name.startswith(f"{prefix}_"):
return rank, 0, _natural_key(name)
return 7, 0, _natural_key(name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _report._name_key(_report.py 147-158 行)对带并发后缀的结果名分组失效:turn1_c8、turn5_c8 这类名字的 suffix 是 _c8,suffix[0] 为 _ 而不是数字,命中 suffix and suffix[0].isdigit() 的分支不会成立,最终落入默认 rank 7("自定义 benchmark")。

触发条件: 使用文档/README 主推的 --concurrency 1,8,16,32 运行 turn1/turn5/turn10、loop1/loop5/loop10 后生成报告,表中会同时出现基础名结果和 _cN 后缀结果。

实际影响: turn1_c8、loop5_c16 等并发结果被排在表格末尾的自定义区段,而不是紧挨对应的 turn1/loop5 行;报告 Notes 承诺的 "turn1/turn5/turn10 和 loop1/loop5/loop10 分别放在一起" 被破坏,并发对比表难以对照阅读,压测主场景的报告排版与实际不一致。

修正方向: 在 _name_key 中先将 _c<数字> 后缀剥离后再做前缀匹配(例如对 name 先 re.sub(r'_c\d+$', '', name) 再按现有逻辑分组),或对 turn/loop 前缀额外接受 _cN 形式的后缀,使并发变体与基础项同组排序。

Comment on lines +104 to +118
def _collect(
results_root: Path,
targets: list[str],
baseline: str | None,
) -> dict[str, Any]:
results: list[dict[str, Any]] = []
for target in targets:
for path in sorted((results_root / target).glob("*.json")):
results.append(json.loads(path.read_text(encoding="utf-8")))
return {
"results_dir": str(results_root),
"targets": targets,
"baseline": baseline,
"results": results,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: run_bench 每次运行前不清理 results_root/<target>/ 目录,而 _collect(run_bench.py 104-118 行)与 build_report 的 _load(_report.py 86-97 行)都以 glob("*.json") 全量读取目标目录下所有结果文件。

触发条件: 在同一 --results-dir 下先跑完整 12 项或 --concurrency 1,8,之后再跑只含 turn1 或不同并发集合的运行,旧运行遗留的 turn5.json、turn10_c8.json 等文件不会被删除。

实际影响: summary.json、report.md、report.html 会把上一次运行(可能对应不同代码版本或不同并发配置)的结果当作本次运行数据混合展示,甚至出现同一 benchmark 新旧两行、比较表基线错配,压测数字失真且难以察觉。

修正方向: 每次运行开始时按 target 清空 results_root/<target>(或先记录本次将要写入的结果名,收集/出报告时只纳入本次生成的文件),并在写 summary.json 时标注运行时间供核对。


DESCRIPTION = ("批量构造 agent 并一直持有,不释放。"
"memory = 每个常驻 agent 占用的内存,不是构造过程中的瞬时峰值。")
AGENTS_PER_SAMPLE = max(1, int(os.environ.get("BENCH_MEMORY_AGENTS", "1000")))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: memory_footprint.py 第 21 行直接 int(os.environ.get("BENCH_MEMORY_AGENTS", "1000")),环境变量值未做类型校验;该变量在文档/README 中也没有任何说明。

触发条件: 用户(或 CI 环境)设置了非数字的 BENCH_MEMORY_AGENTS(例如空字符串、auto),运行 memory_footprint 压测项时模块导入阶段即抛 ValueError。

实际影响: 压测子进程在 _runner 加载模块时直接崩溃,run_bench 因 check=True 整体失败退出,且报错只是裸的 int() 异常,用户无法判断是哪个配置出了问题。

修正方向: 用与 _positive_int/_parse_concurrency 一致的校验方式解析该变量,解析失败时给出明确错误信息(或回退默认值并告警),并在文档中登记该环境变量的含义。

self._connection: Any = None

def start(self) -> str:
context = multiprocessing.get_context("fork")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: MockOpenAIServer.start()(_model.py 355-375 行)使用 multiprocessing.get_context("fork") 硬编码 fork 启动方式。

触发条件: 在 Windows 上运行压测套件(fork 上下文不存在,get_context("fork") 直接抛 ValueError);在 macOS 上从已导入 fastapi/openai 等线程化库的进程中 fork,存在子进程挂起风险。

实际影响: 套件被打包为跨平台 wheel(pyproject 支持 3.10-3.12,无平台限制),但在 Windows 上整套压测无法运行;macOS 上 mock server 可能无法在 20 秒内上报端口,start() 抛 "mock OpenAI server did not report a port"。

修正方向: 通过 multiprocessing.get_context("spawn")(或默认上下文)启动子进程,并把 spec/model_conf 以参数或序列化方式传入,避免依赖 fork 语义。

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围:594bfb0..6ea849d,共 52 个文件、+6380 行,主体为新增 trpc_agent_bench 压测套件(run_bench.py 编排、_eval.py 测量与断言、_model.py OpenAI 兼容 Mock 服务、_runner.py 子进程入口、_report.py 报告、bench/llmagent/base.py 的 LlmAgent target、13 个内置 benchmark)以及 13 个测试文件,另含 pyproject/文档改动。

计划符合性:套件按设计文档落地——Mock 模型驱动真实 agent loop、按 (target, benchmark, concurrency) 子进程隔离、warmup→timed→memory 分离、median/p95 统计、多 target 对比报告。经代码级验证:mock 的 SSE 线格式与 SDK 流式消费(_process_chunk_without_content/_process_tool_call_delta/_create_complete_tool_calls)、工具调用 id 回填与函数回复匹配一致,工具/循环 benchmark 可端到端跑通;HTTP 500 会经错误事件在 ensure_turn_completed 处明确失败,不会静默挂起。

主要风险:(1) 会话型 benchmark 的内存指标被跨迭代累积、从未释放的会话驻留污染(MODERATE);(2) mock 的 ModelReply.error 错误注入分支为死代码(LOW);(3) 缺少 SDK↔Mock 的端到端集成测试,工具/并行工具/循环流程仅被单元测试覆盖(LOW)。

测试充分性:13 个测试文件覆盖编排参数、请求定位、统计、报告、mock 线协议与配置校验,但无真实 agent 对 mock 服务的集成测试。

门禁结论:未发现 SEVERE 级别问题,变更与计划相符,state=PASSED。

发现的问题

中等

trpc_agent_bench/bench/_builtin/_utils/_session_bench.py:49-67

问题: 会话型内置 benchmark(turn1/5/10、loop1/5/10、tools_parallel)的每个采样迭代都会通过 new_session_id() 创建一个全新的会话 id,而 bench/llmagent/base.py 创建的 InMemorySessionService 默认 TTL 为 24 小时、close() 只停止后台清理任务并不清空 _sessions;tools.py 的 tools benchmark 每轮采样同样新建会话。整个内存采样阶段(PerformanceEval._memory_async 执行 --iterations 次)中,所有迭代产生的会话与完整事件历史(各轮 SSE 文本、工具调用记录及 get_session 中 filter_events 的深拷贝)持续驻留,后一次采样的 tracemalloc 峰值必然包含此前全部会话累积触发的分配。

触发条件: 以 --iterations N(N>1)运行任一上述 benchmark:第 1 次采样的峰值只含本次会话的分配,第 N 次采样前服务里已保留 N-1 个会话,且每次 run_turn 都要对服务内全部事件记录做深拷贝,峰值随 N 放大。

实际影响: 报告的 memory_usages 与 median_memory_usage 随 --iterations 变化且系统性偏大,baseline 与 target 之间的内存对比失真;残留会话还持续占用真实堆,令 timed pass 的真实内存同样随迭代增长,违背指标描述中"本次采样中 tracemalloc 记录的峰值"的独立采样语义。

修正方向: 让每个采样度量独立过程:在每次迭代完成后删除本次会话(通过 AgentHandle 暴露 delete_session 或等效清理接口),或在每个内存样本前重建 session service(重新执行 setup()/teardown()),使每个样本只包含自身的分配峰值。

较低

trpc_agent_bench/bench/_model.py:256-257

问题: chat() 中的 if model_reply.error: 分支是死代码:ModelReply.error(_model.py:56)没有任何赋值路径,reply_from_script 只构造文本或工具调用两类回复,因此该分支永不触发,Mock 服务的"脚本化错误注入"能力实际不存在。

触发条件: 自定义 benchmark 或错误路径测试期望通过脚本化的 ModelReply.error 触发 HTTP 500(进而验证 SDK 错误事件与 ensure_turn_completed 的报错路径)时,该分支不会执行。

实际影响: 错误注入只能依赖脚本越界(IndexError → 500),无法确定性构造模型错误响应,SDK 侧错误事件的端到端验证缺少可控手段。

修正方向: 在 _reply_spec/reply_from_script 中支持错误构造(例如允许 TextBlock 携带 error 或新增错误块类型),使 ModelReply.error 可被赋值并覆盖该 500 分支;或删除该分支并在文档中明确错误注入方式。

较低

trpc_agent_bench/bench/_builtin/tools.py:41-52

问题: 依赖 SDK 真实执行路径的 benchmark(tools、tools_parallel、loop*)没有任何端到端测试:现有单测用 SimpleNamespace 伪造事件直接调用 ensure_turn_completed,或用独立的最小客户端验证 mock 的 SSE 线格式,从未用真实的 LlmAgent + OpenAIModel 连接 MockOpenAIServer 跑完一轮完整会话。

触发条件: SDK 侧行为变化(事件部分标志、turn_complete/is_final_response 语义、工具调用 id 匹配、_merge_parallel_function_response_events 合并逻辑)回归时,现有测试无法在合入前捕获。

实际影响: tools_parallel 依赖"3 个 FunctionBlock 合并为单个含 3 个 function_response part 的事件"这一 SDK 内部行为(responses < 3 断言),ensure_turn_completed 则依赖"SDK 从不置 turn_complete,只能靠 is_final_response() 判定完成"这一隐含约定;一旦 SDK 变更,套件可能只在真实压测中才暴露失败,且 mock 服务 500 → 错误事件 → RuntimeError 的失败路径同样未经测试。

修正方向: 新增端到端集成测试:真实构造 LlmAgent/OpenAIModel(base_url 指向 mock server)执行 tools/tools_parallel 脚本,断言工具链执行、返回事件数及 ensure_turn_completed 通过;并补测 HTTP 500 场景下错误事件被正确识别并抛出的路径。

Comment on lines +49 to +67
async def one_session(index: int) -> None:
user_id = _session_user_id(base_user_id, index)
session_id = new_session_id()
for turn in script:
events = await drain(
handle.run_turn(
app_name=app_name,
user_id=user_id,
session_id=session_id,
message="Say ok.",
), )
ensure_turn_completed(
events,
expected_text=_expected_text(turn),
expect_tool_success=_has_function(turn),
)

async def arun_conversation() -> None:
await asyncio.gather(*(one_session(index) for index in range(current)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 会话型内置 benchmark(turn1/5/10、loop1/5/10、tools_parallel)的每个采样迭代都会通过 new_session_id() 创建一个全新的会话 id,而 bench/llmagent/base.py 创建的 InMemorySessionService 默认 TTL 为 24 小时、close() 只停止后台清理任务并不清空 _sessions;tools.py 的 tools benchmark 每轮采样同样新建会话。整个内存采样阶段(PerformanceEval._memory_async 执行 --iterations 次)中,所有迭代产生的会话与完整事件历史(各轮 SSE 文本、工具调用记录及 get_session 中 filter_events 的深拷贝)持续驻留,后一次采样的 tracemalloc 峰值必然包含此前全部会话累积触发的分配。

触发条件: 以 --iterations N(N>1)运行任一上述 benchmark:第 1 次采样的峰值只含本次会话的分配,第 N 次采样前服务里已保留 N-1 个会话,且每次 run_turn 都要对服务内全部事件记录做深拷贝,峰值随 N 放大。

实际影响: 报告的 memory_usages 与 median_memory_usage 随 --iterations 变化且系统性偏大,baseline 与 target 之间的内存对比失真;残留会话还持续占用真实堆,令 timed pass 的真实内存同样随迭代增长,违背指标描述中"本次采样中 tracemalloc 记录的峰值"的独立采样语义。

修正方向: 让每个采样度量独立过程:在每次迭代完成后删除本次会话(通过 AgentHandle 暴露 delete_session 或等效清理接口),或在每个内存样本前重建 session service(重新执行 setup()/teardown()),使每个样本只包含自身的分配峰值。

Comment on lines +256 to +257
if model_reply.error:
raise HTTPException(status_code=500, detail=model_reply.error)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: chat() 中的 if model_reply.error: 分支是死代码:ModelReply.error(_model.py:56)没有任何赋值路径,reply_from_script 只构造文本或工具调用两类回复,因此该分支永不触发,Mock 服务的"脚本化错误注入"能力实际不存在。

触发条件: 自定义 benchmark 或错误路径测试期望通过脚本化的 ModelReply.error 触发 HTTP 500(进而验证 SDK 错误事件与 ensure_turn_completed 的报错路径)时,该分支不会执行。

实际影响: 错误注入只能依赖脚本越界(IndexError → 500),无法确定性构造模型错误响应,SDK 侧错误事件的端到端验证缺少可控手段。

修正方向: 在 _reply_spec/reply_from_script 中支持错误构造(例如允许 TextBlock 携带 error 或新增错误块类型),使 ModelReply.error 可被赋值并覆盖该 500 分支;或删除该分支并在文档中明确错误注入方式。

Comment on lines +41 to +52
def make_tools() -> List[BaseTool]:
return [FunctionTool(add_numbers)]


async def _run() -> None:
box = await setup(TURN_SCRIPT)
handle = await box.target.handle(box.prepared, tools_factory=make_tools)

async def arun_agent() -> None:
events = await drain(
handle.run_turn(
app_name=box.conf.app_name,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 依赖 SDK 真实执行路径的 benchmark(tools、tools_parallel、loop*)没有任何端到端测试:现有单测用 SimpleNamespace 伪造事件直接调用 ensure_turn_completed,或用独立的最小客户端验证 mock 的 SSE 线格式,从未用真实的 LlmAgent + OpenAIModel 连接 MockOpenAIServer 跑完一轮完整会话。

触发条件: SDK 侧行为变化(事件部分标志、turn_complete/is_final_response 语义、工具调用 id 匹配、_merge_parallel_function_response_events 合并逻辑)回归时,现有测试无法在合入前捕获。

实际影响: tools_parallel 依赖"3 个 FunctionBlock 合并为单个含 3 个 function_response part 的事件"这一 SDK 内部行为(responses < 3 断言),ensure_turn_completed 则依赖"SDK 从不置 turn_complete,只能靠 is_final_response() 判定完成"这一隐含约定;一旦 SDK 变更,套件可能只在真实压测中才暴露失败,且 mock 服务 500 → 错误事件 → RuntimeError 的失败路径同样未经测试。

修正方向: 新增端到端集成测试:真实构造 LlmAgent/OpenAIModel(base_url 指向 mock server)执行 tools/tools_parallel 脚本,断言工具链执行、返回事件数及 ensure_turn_completed 通过;并补测 HTTP 500 场景下错误事件被正确识别并抛出的路径。

@raychen911
raychen911 merged commit b33f164 into main Nov 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants