benchmark_qa_planning.py 5.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. """Compare planning with both optimizations off/on; real LLM calls require authorization.
  2. --execute additionally runs schema-validated read-only Neo4j queries. No raw query
  3. results are saved. Planning times exclude confirmation, query execution and answer.
  4. """
  5. from __future__ import annotations
  6. import argparse
  7. import hashlib
  8. import json
  9. import os
  10. from pathlib import Path
  11. from time import perf_counter
  12. from unittest.mock import patch
  13. from step3_qa_agent.agent import nodes, production, llm
  14. from step3_qa_agent.retrieval.production_query import compile_query
  15. from step2_graph_building.runtime import release_snapshot
  16. def query_fingerprint(plan, context):
  17. if release_snapshot()['data_version'] != context['build_id']:
  18. raise RuntimeError('数据版本变化,取消对比')
  19. driver = production.get_driver()
  20. results = []
  21. for step in plan['steps']:
  22. spec = step['params']
  23. compiled = compile_query(spec, context['schema'], context['build_id'])
  24. records = driver.execute_query(compiled.query, **compiled.params).records
  25. aliases = {n['alias']: n['type'] for n in spec['nodes']}
  26. columns = [(str((aliases[c['alias']], c.get('op', 'field'), c.get('field'))), c['as'])
  27. for c in spec.get('select', []) + spec.get('aggregates', [])]
  28. rows = [sorted((identity, row.get(label)) for identity, label in columns) for row in records]
  29. encoded = sorted(json.dumps(row, ensure_ascii=False, sort_keys=True, default=str) for row in rows)
  30. results.append({'rows': len(records), 'truncated': len(records) > compiled.limit,
  31. 'sha256': hashlib.sha256(json.dumps(encoded, ensure_ascii=False).encode()).hexdigest()})
  32. if release_snapshot()['data_version'] != context['build_id']:
  33. raise RuntimeError('查询期间数据版本变化,取消对比')
  34. return results
  35. def main():
  36. parser = argparse.ArgumentParser(description=__doc__)
  37. parser.add_argument('--question', action='append')
  38. parser.add_argument('--repeat', type=int, default=1)
  39. parser.add_argument('--execute', action='store_true', help='实际执行受控只读查询,仅保存结果摘要')
  40. parser.add_argument('--output', default='.runtime/qa/planning-benchmark.json')
  41. args = parser.parse_args()
  42. if args.repeat < 1:
  43. parser.error('--repeat 必须大于0')
  44. questions = args.question or ['共有多少名员工?', '岗位名称包含保安的员工有多少名?',
  45. '张三的岗位名称是什么?', '按岗位名称分别统计员工人数']
  46. output = Path(args.output)
  47. output.parent.mkdir(parents=True, exist_ok=True)
  48. report = {'scope': 'understand + production_plan only; both switches off vs on',
  49. 'executed_read_only_queries': args.execute, 'runs': []}
  50. original_chat = llm.get_chat
  51. def bounded_chat(*a, **kw):
  52. model = original_chat(*a, **kw)
  53. model.request_timeout = 90
  54. model.max_retries = 0
  55. return model
  56. for repeat in range(args.repeat):
  57. for index, question in enumerate(questions):
  58. for enabled in ([False, True] if (repeat + index) % 2 == 0 else [True, False]):
  59. row = {'question': question, 'repeat': repeat, 'optimizations_enabled': enabled}
  60. started = perf_counter()
  61. try:
  62. with patch.dict(os.environ, {
  63. 'QA_FAST_PLAN_ENABLED': str(enabled).lower(),
  64. 'QA_COMPACT_INTERMEDIATE_ENABLED': str(enabled).lower()}), \
  65. patch.object(llm, 'get_chat', side_effect=bounded_chat):
  66. state = {'question': question}
  67. state.update(nodes.understand(state))
  68. state.update(production.plan(state))
  69. row.update(total_ms=round((perf_counter() - started) * 1000, 2),
  70. build_id=state['qa_context']['build_id'], trace=state['trace'],
  71. plan=state['plan'], candidate=state.get('simple_query'))
  72. if args.execute:
  73. row['query_results'] = query_fingerprint(state['plan'], state['qa_context'])
  74. row['status'] = 'ok'
  75. except Exception as exc:
  76. row.update(status='error', error_type=type(exc).__name__,
  77. total_ms=round((perf_counter() - started) * 1000, 2))
  78. report['runs'].append(row)
  79. output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
  80. trace = row.get('trace', [])
  81. print(json.dumps({'question': question, 'optimized': enabled, 'status': row['status'],
  82. 'ms': row['total_ms'], 'mode': trace[-1].get('mode') if trace else None,
  83. 'reason': trace[-1].get('reason') if trace else None,
  84. 'llm_streams': sum(len(t.get('llm_calls', [])) for t in trace)}, ensure_ascii=False), flush=True)
  85. print('Saved: ' + str(output), flush=True)
  86. return 1 if any(r['status'] != 'ok' for r in report['runs']) else 0
  87. if __name__ == '__main__':
  88. raise SystemExit(main())