aidata · case walkthrough

water_emergency

水费异常排查 + 报障 + 营业厅导航 — 微信生活缴费 × 高德地图 跨 APP 协同的条件分支决策场景。

本次得分
0.800 (12/15)
运行耗时
271.6 s
Agent steps
15 / 21 tool calls
Token 累计
278k+3.5k
prompt + completion · 15 step 累计
模型: gemini-3.1-pro-preview · 沙箱 image: aio-agent-rc:v0.1-dev · mock image: mock-server:v0.0.10-services · 跑于 2026-04-26

① TL;DR — 一分钟讲清

Agent 拿到一段口语化生活诉求("水费上个月忘交、感觉不太对、帮我看一下、有问题就报障、没问题就缴费、顺便看天气导航"), 在 三个跨 APP mock(微信宿主、生活缴费小程序、高德地图)之间穿梭, 最后做出 "先报障、不缴费" 的正确决策。

技术上 case 演示了三个东西:条件分支(异常→报障 vs 正常→缴费)、 硬隔离架构(mock 跑在另一个容器、agent 看不到源码)、 三层 Verifier(共 15 条:regex 8 条钉骨架、llm_judge 4 条管语义、business 类 regex 3 条交叉验证)。 跑一次需要 ~5 分钟,主要消耗在 sandbox / mock 容器拉起和 agent 多轮 LLM 调用。

② 怎么跑 — 5 行命令

前置:完成 Nexus-AIDATA 的 setup-nexus-aidata skill §0–§4(uv 装好、submodule 拉好、.env 配好 GONGFENG_TOKEN + LLM key)。该 skill 在 Nexus-AIDATA 仓库的 .cursor/skills/setup-nexus-aidata/SKILL.md 下。

# 1. 进 harness 仓
cd Nexus-AIDATA

# 2. 加载 .env(GONGFENG_TOKEN / SEARCH_OPENAI_* / JUDGE_OPENAI_*)
source .env

# 3. 跑!耗时 ~5 分钟(容器拉起 ~60s + agent 主循环 ~3 分钟 + judge 评分 ~30s)
uv run python nexus/extensions/tasks/aidata/aidata.py run water_emergency -v

# 4. 结果文件路径会打印在日志开头,长这样:
#    workspace/mcp/tmp/aidata_water_emergency_<ts>.result.json
# 它包含 score / rubric_results / agent_output.main_trajectory 全量。
展开:一次跑通的关键日志锚点(搜这些字符串能快速定位阶段)
日志关键字含义
Acquiring runtime 'main'主容器(agent 容器)从 Gongfeng sandbox 拉起
service lifegym-mocks readymock 容器 3 个端口(30001/30002/30010)就绪
resolved placeholders__MOCK_*_URL__ 占位符注入完成
bootstrap stdoutsetup.sh 执行结果
Executing step N/50agent 第 N 步(max_iterations=50)
🛠️ Selected tools当前步骤选了哪些工具
[rubric] manifestrubric 加载,开始评分
[aidata] done task=water_emergency评分完成、最终 score

③ 业务场景

用户原话(agent 看到的 prompt)

我家水费上个月忘交了,刚才看了下水表读数感觉不太对,比平时高了快一倍,你先帮我查一下近半年的用水量趋势,看看是不是有异常, 如果确实有问题就帮我报个障让人来检查水表。没问题的话就直接把欠费交了,记得开电子发票抬头用我们公司的。 另外我下午两点要去自来水公司营业厅现场问一下,帮我导航一下最近的营业厅在哪,顺便看看今天那边天气怎么样,下不下雨我好决定骑车还是打车过去。

拆开看,agent 必须做 4 件事

  1. 查近 6 个月用水量趋势,判断当月是否异常
  2. 条件分支:异常 → 报障(不缴费);正常 → 缴费 + 开公司抬头电子发票
  3. 查天气,决定出行方式
  4. 搜索并导航到最近的自来水公司营业厅

为什么这个 case 难

  • 分支决策必须基于工具返回数据动态判断,不是简单的 if-else 流程图
  • 跨 APP 信息依赖:天气数据 → 出行决策;位置 → POI 搜索半径
  • 互斥性:报障和缴费是互斥的,agent 必须只走一条路径
  • 不能只完成动作,必须把 4 项信息都讲给用户听

④ 运行架构 — 双容器硬隔离

本 case 用 services + aiohttp_streamable_http transport, 把 mock 推到 另一个 sandbox 容器 里跑,agent 容器通过 HTTP MCP 调用,从而: ① agent 进程读不到 mock 源码; ② agent 进程读不到 data_patch.json; ③ 防止它"作弊"用 shell 直接看真值。

flowchart LR subgraph H["主机 / Cursor"] H0["aidata.py run
water_emergency"] end subgraph S["Gongfeng Sandbox"] subgraph M["main container
aio-agent-rc:v0.1-dev"] A["wide_agent
15 step / 21 tool calls"] P["MCP proxy"] A --- P end subgraph X["service container · service_lifegym_mocks
mock-server:v0.0.10-services"] W["wechat_host
:30001"] L["life_payment
:30002"] G["amap
:30010"] end end LLM["LLM
gemini-3.1-pro"] JUDGE["Judge LLM
同上"] H0 -->|HTTP| M H0 -->|HTTP| X A -->|"OpenAI
chat/completions"| LLM P -.->|"streamable-http
__MOCK_WECHAT_URL__"| W P -.->|"streamable-http
__MOCK_LP_URL__"| L P -.->|"streamable-http
__MOCK_AMAP_URL__"| G H0 -->|trajectory| JUDGE
组件镜像 / 模型职责对 agent 是否可见
main 容器aio-agent-rc:v0.1-dev跑 wide_agent + 102 个 MCP 工具的 catalog—
service_lifegym_mocksmock-server:v0.0.10-services3 个 mock 共一个进程,监听三端口只暴露 MCP HTTP,源码 + state 不可见
LLM (主)gemini-3.1-pro-previewagent 决策—
LLM (judge)同上给 4 条 llm_judge check 打分—
展开:占位符注入怎么发生

case.yaml::services[].connections[] 里声明了 3 个 placeholder(__MOCK_WECHAT_URL__/__MOCK_LP_URL__/__MOCK_AMAP_URL__), engine 在 service 容器拉起后,把每个 placeholder 替换成真实的 http://nexus-<rid>.<port>.hyrl-sandbox.prod.woa.com:8053/mcp, 再把替换后的 URL 写到 extra_mcp_servers[].client.url。这样 agent 完全不知道真实 host/port,只能通过 MCP 协议调用。

⑤ Mock 数据设计 — 关键阈值制造分支

data_patch.json 一共 17 张表 449 行 JSON,被 base64+gzip 后塞进 case.yaml::services[].startup.env.DATA_PATCH_B64,service 容器启动时由 mock_startup.sh 解码并 seed 给 mock。 其中两个关键字段直接决定了 case 的"正确路径":

用水量异常阈值
近 6 个月: 8.2 → 9.1 → 8.7 → 9.5 → 8.9 → 17.8 吨
历史均值: 8.88
阈值: 1.5 × 均值 = 13.32
17.8 > 13.32 → anomaly_flag = true
天气制造打车信号
日期: 2024-07-01
天气: 多云转阵雨
降水概率: 75%
应建议打车 / 室内交通

完整数据切片

点击表名跳到附录 A 中 data_patch.json 对应位置自动展开;或直接打开 📁 data_patch.json / 📁 rubric.yaml。

表行数关键字段
wechat_users1张三, 杭州, openid=oUser_ZhangSan_001
wechat_authorizations2userinfo + scope.userLocation 已授权
wechat_locations1当前位置:文二西路 89 号
life_payment_accounts2水费户号 330106-2024-08752 + 电费户号
life_payment_bills46 月水费 89.5 元未缴;4-5 月已缴
life_payment_usage_history66 月 17.8 吨带 anomaly_flag=true
amap_pois3西湖 / 拱墅 / 滨江 三个营业厅
amap_weather27/1 阵雨 75% / 7/2 转多云
amap_routes3驾车 12min / 步行 28min / 公交 22min
… 还有 invoice_titles / notifications / reminders / orders / complaints 等共 17 张(附录 A 中查看完整列表)

⑥ 真实执行时间线 — 这次跑出来的 15 个 step

下表是本次运行 agent 实际做的每一步(来自 main_trajectory)。 可以看到 agent 在 step 4 就基于趋势数据下了报障决策(早于天气和 POI 查询),整体很有结构感。

#耗时工具调用含义 / 关键返回
111s wx_login 拿到 openid=oUser_ZhangSan_001
214s lp_query_bound_accounts
wx_authorize_scope
拿到 2 个户号(水 + 电);提前申请位置授权
39s lp_query_usage_curve ⭐
lp_query_unpaid_bills
wx_get_location
看到 6 月 17.8 吨 vs 历史 8-9 吨;6 月账单 89.5 未缴;位置工具返回 auth_required(坑 1)
420s lp_report_outage ✅
wx_get_location
关键决策:识别异常 → 提报障。返回 ticket_id OT20260426143337615C48,预计 240 分钟响应
57swx_authorize_scope 用不带 scope. 前缀的 userLocation 重试授权
64swx_get_location 位置成功:杭州市西湖区文二西路 89 号
75s amap_text_search
amap_weather
POI 搜索 "自来水营业厅" → 0 条;天气:"多云转阵雨" 75% 降水(坑 2)
85s amap_text_search 关键词:"水务集团营业厅" → 0 条
96s amap_text_search 关键词:"水务 营业厅" → 0 条
105s amap_text_search 关键词:"供水营业厅" → 0 条
116s amap_text_search 关键词:"营业厅" → 0 条(4 次 text_search 共 ~22s)
125samap_around_search 第 1 次 around 搜索仍 0 条
1310samap_around_search ✅ 第 2 次 around 搜索找到 3 个营业厅,最近:西湖营业厅(教工路 58 号,6076m)
145s amap_navigation
amap_ride_hailing
生成导航 deep link + 打车 deep link
1524s finish 4 节中文回复(异常分析 + 报障号 + 营业厅 + 天气&打车建议)

完整轨迹(每步可展开)

上表是总结视图,下面是从 main_trajectory 直接渲染的真实数据 — 点击任意一行展开看 这一步的 tool call 参数 + tool 返回 JSON。

展开:finish 工具的完整 message(agent 给用户的最终回复)
1. **用水量趋势及异常报修**:
   我为您查询了近6个月的水费账单,数据显示您在今年前五个月的用水量均在 8-9 吨左右,
   而 6 月份的用水量达到了 17.8 吨,超过了近半年平均值的 1.5 倍,系统也提示异常偏高。
   因为情况确实异常,我暂时**没有直接为您交纳本月的费用**,而是以"水量异常偏高,
   申请上门检查水表"为由,**帮您向杭州市水务集团发起了一个报修/申诉工单**。
   工单号是 `OT20260426143337615C48`,预计240分钟内会有人联系响应。

2. **自来水营业厅导航**:
   距离您最近的营业厅是**杭州市水务集团西湖营业厅**,
   位于杭州市西湖区教工路58号,距离您当前位置约 6 公里。

3. **天气及出行建议**:
   杭州市今天**下午多云转阵雨**(降水概率75%),所以**建议您打车或者选择室内交通方式**前往,
   骑车很容易淋雨。

⑦ 分支决策 — case 的核心设计

flowchart TD Start(["用户 prompt"]) --> Q1["lp_query_bound_accounts
拿到水费户号"] Q1 --> Q2["lp_query_usage_curve
近 6 个月趋势"] Q2 --> J{"当月用量 > 1.5×均值?"} J -->|否 / 正常| P1["lp_create_order
缴费"] P1 --> P2["lp_issue_invoice
开公司抬头发票"] J -->|是 / 异常 ✅本次| R1["lp_report_outage
提交报障工单"] R1 --> R2["明确告知用户
暂不缴费"] P2 --> NEXT["maps_weather + maps_text_search
maps_navigation"] R2 --> NEXT NEXT --> Final(["finish
4 项信息一次交付"]) style J fill:#fef3c7,stroke:#f59e0b,stroke-width:2px style R1 fill:#dcfce7,stroke:#16a34a,stroke-width:2px style R2 fill:#dcfce7,stroke:#16a34a,stroke-width:2px style P1 fill:#fee2e2,stroke:#dc2626,stroke-width:1px,stroke-dasharray: 4 4 style P2 fill:#fee2e2,stroke:#dc2626,stroke-width:1px,stroke-dasharray: 4 4

绿色 本次走的实际路径; 虚线红 数据正常时才会走的另一条路径(rubric 也允许,但本 case 数据是异常)。 互斥性由 no_conflicting_actions check 强制:同时报障 + 缴费视为错。

⑧ Verifier 设计 — 15 条三层 check

遵循 "能用 regex 不用 LLM" 原则:80% 的判分走确定性正则,只有"语义模糊"的部分才掏 llm_judge 出来。 分三层组织:

Layer 1 · 工具调用骨架
4 条 regex,确认 agent 调过哪几个关键工具(用量/账单/天气/POI)。最便宜,零 LLM 开销。
Layer 2 · 顺序与返回值
4 条 regex,校验调用顺序(dotall)+ 返回值(成功标志、含降水信息、含营业厅)。
Layer 3 · 业务语义
4 条 llm_judge + 3 条业务 regex,判断分支选对、信息完整、互斥、工单实际提交、异常实际识别等。
15 条 check 全表(点击折叠)
#check idverify判什么
1must_use_lp_query_usageregex调过 lp_query_usage_curve
2must_use_lp_query_billsregex调过 lp_query_unpaid_bills 或 lp_query_arrears
3must_use_maps_weatherregex调过 maps_weather
4must_use_maps_searchregex调过 maps_text_search 或 maps_around_search
5query_before_actionregex (dotall)顺序:查户号 → 查用量 → 报障/缴费
6outage_report_successregex报障返回 "status":"success"
7weather_data_receivedregex天气返回含降水/天气描述
8service_hall_foundregexPOI 返回里含 "水务/供水…营业厅"
9correct_branch_anomalyllm_judge分析趋势 → 走报障路径,不缴费
10weather_informed_transportllm_judge基于天气数据给出出行建议(非通用建议)
11no_conflicting_actionsllm_judge报障与缴费互斥,只走一条
12complete_information_deliveryllm_judge4 项信息一次交付完整
13biz_outage_report_submittedregex报障返回含 complaint_id/ticket_id/工单号/WO- + 48小时/24小时/工作日
14biz_usage_anomaly_detectedregextrajectory 中明确出现 17.8 与 "异常" 的关联
15biz_navigation_providedregex给出具体路名(教工路/竞舟路/解放东路/庆春路)+ 营业厅

⑨ 真实评分结果 — 12/15 = 0.800

本次跑下来的实际通过情况:

通过idverify原因
✓must_use_lp_query_usageregexstep 3 调过
✓must_use_lp_query_billsregexstep 3 调过
✓must_use_maps_weatherregexstep 7 调过
✓must_use_maps_searchregexstep 7-13 调多次
✓query_before_actionregexstep 2→3→4 顺序正确
✓outage_report_successregexstep 4 返回 success
✓weather_data_receivedregex返回含 "多云转阵雨" + 降水概率
✓service_hall_foundregexstep 13 找到水务集团营业厅
✓correct_branch_anomalyllm_judge判 Y:识别异常并报障
✗weather_informed_transportllm_judge判 N(见下方根因 1)
✓no_conflicting_actionsllm_judge判 Y:只报障未缴费
✗complete_information_deliveryllm_judge判 N(见下方根因 2)
✗biz_outage_report_submittedregex正则不匹配(见下方根因 3)
✓biz_usage_anomaly_detectedregextrajectory 含 17.8 + 异常
✓biz_navigation_providedregexfinal answer 含 "教工路 58 号" + "营业厅"

3 条失败的根因拆解

① weather_informed_transport · llm_judge → N

Agent 实际上给出了完美的回答:"杭州市今天下午多云转阵雨(降水概率75%),所以建议您打车或者选择室内交通方式" — 数据引用具体、推理清晰。但 judge 仍判 N。
怀疑:trajectory 里的 finish 工具消息可能被切到 tool_result_chars: 500 上限以下,judge 看到的版本不完整。 验证手段:在 rubric.yaml::config.judge.source_max_chars 调到 8w+,或把 finish 文本也算进 trajectory 切片里。

② complete_information_delivery · llm_judge → N

4 项信息(异常分析 / 报障动作 / 营业厅 / 天气出行)肉眼看 全部都在 finish 消息里。 Judge 判 N 同样可疑。和 ① 同根因 — 判 N 的不是 agent 输出有缺失,而是 judge 拿到的 source 被裁剪。
结论:rubric 这两条 llm_judge 当前对 "短小但完整的 final answer" 不够 robust, 建议把 final answer 单独拼到 judge prompt 顶部,不参与 trajectory 整体 trim。

③ biz_outage_report_submitted · regex → N

正则期望 (complaint_id|ticket_id|工单号|WO-).*?(48小时|24小时|工作日)。
Mock 实际返回的 ticket_id OT20260426143337615C48 ✅ 命中前半, 但 expected_resolution 是 "240分钟内" ❌ 不匹配后半 (48小时|24小时|工作日)。
本质:Mock 是动态生成 ticket(不复用 data_patch.json 里预置的 WO-2024070100123), rubric 规则没考虑到这点。
修法:在正则里加 240分钟 或更宽 \d+(分钟|小时|工作日); 或者让 mock 报障接口和 data_patch 里的 expected_resolution 对齐。

⑩ 学到的 / 改进方向

这个 case 设计上做对的点(值得复用)

  • 用数值阈值制造分支,而不是写 if-else 在 prompt 里 — 让 agent 真的"分析数据",提高判分鲁棒性
  • 硬隔离(双容器 + HTTP MCP)防止 agent 用 shell 直接读 mock 源码作弊;适合作为高质量 RL reward 来源
  • Verifier 三层结构:骨架 / 顺序 / 语义 — 即使语义层 LLM 不稳,前两层依然能给出确定性 8/15
  • "绑账户 → 查用量 → 决策动作" 顺序约束用 dotall regex 实现,比纯 LLM 判更稳

这次跑暴露的问题(待修)

  • llm_judge 对 finish 输出敏感:当 final answer 被卷进 trajectory trim 时,judge 拿到的 source 可能不完整。 → 调 rubric.yaml::config.judge.source_max_chars 或修改 engine trajectory 拼接顺序。
  • regex 与 mock 动态返回不一致:biz_outage_report_submitted 假设了固定预期处理时间。 → 要么放宽 regex,要么对齐 mock 动态返回与 data_patch 预置值。
  • POI 关键词搜索 5 次才中:amap_text_search 对中文模糊关键词召回低,agent 浪费了 6 步在重试。 → Mock 端可以加同义词索引,或把 around_search 设成首选。
  • wx_get_location 第一次返回 auth_required:data_patch 里 scope.userLocation 已 granted, 但 mock 实现要求请求时 scope 字段写 userLocation(不带前缀)。 → 这是 mock 行为契约,建议在 mock 工具描述里写清楚。

给同类 case 设计者的 takeaways

  1. 先用 regex 把骨架钉死再上 llm_judge,避免少数 judge 翻车整体不可读
  2. 每条 llm_judge 写出 "应当 X / 不应 Y" 的二元 claim,避免开放式表述
  3. 条件分支 case 必须有 no_conflicting_actions 这种互斥校验,否则 agent 会"两边都做"骗分
  4. Mock 数据要有"明显信号"(17.8 vs 8.88 的 1.5× 阈值),太接近边界会让正确路径模糊
  5. 把 "怎么跑" 和 "评分文件路径" 写进 README — 这是新人复跑这个 case 的最低门槛

⑪ 重放 — 模拟一次完整运行

点 ▶ 开始重放,下面的终端会按真实顺序"演"一遍: shell 环境 → aidata.py 启动 → 双容器拉起 → bootstrap → agent 15 步主循环 → rubric 评分 → 出分。 可调速(瞬时 / 1× / 2× / 4×),也可单步前进逐条看。耗时被压缩了:真实 ~270 s 在 1× 下 ~30 s 演完。

| 速度 | 就绪

    
0 / 0 压缩时间轴 · 实际耗时 271.6 s

提示:终端里 $ 黄色 = 主机命令,蓝色 = info 日志, 绿色 = 成功 / ready,紫色 = 阶段标题, 青色 = agent 步骤标题。

附录 A · Case 源文件 — 仓库目录浏览

本 case 的全部静态资产位于 nexus/extensions/tasks/aidata/cases/water_emergency/。 左侧文件树点击即在右侧查看:case.yaml(默认)展示时会把超长 base64 折叠; README.md 用 Markdown 渲染;data_patch.json 走可折叠 JSON 树(17 张表逐层下钻),亦可切到原始 JSON; rubric.yaml 默认按 check 分块展开。

工具栏右侧三联按钮:↗ 新窗口 直接打开浏览器原始视图; ⤓ 下载 保存原文件到本地;⎘ 复制 把当前展示内容拷到剪贴板。 点击文件名旁的 case.yaml 路径文字也可以复制相对路径。 前文用 📁 看源 标识的链接会带你跳到这里并定位。

YAML (加载中…)
加载中…

改数据 / 改 rubric 后请重新跑一次 case;本页是只读快照。完整业务解释见 ③ 业务场景 与 ⑧ Verifier 设计。

附录 B · 完整 log / 轨迹 — 给想刨细节的人

前面所有展示都是解读,这一节是原始材料。主线是简短摘要,每行点开就是"模型实际收到了什么 / 实际输出了什么 / Agent 实际拿回了什么"。 数据均来自这次跑产出的 result.json + verbose log,已归档到 walkthrough/data/。

12.1 数据文件清单

文件大小含义 / 怎么用
water_emergency.result.json 48 KB engine 原生输出。包含 agent_output.main_trajectory(38 条 OpenAI 格式 message)+ final_answer + rubric_results(15 条评分细节)+ token 统计。
water_emergency.run.log 106 KB / 1095 行 uv run ... -v 的 stderr+stdout 全量。容器拉起、bootstrap、agent 每步 verbose、rubric 评分、judge 调用都在里面。

跑一次新的会写到 workspace/mcp/tmp/aidata_water_emergency_<ts>.result.json; 本 walkthrough 锁定的是 1777213861(2026-04-26 22:35)那次。

12.2 模型 ↔ Agent 完整对话回放(38 条消息)

核心心智模型:整次跑只发起了 15 次 LLM 调用(每次产生一个 assistant message),但每次调用都把"截至当前的所有历史"完整塞回去 —— 所以"模型本轮看到什么"等价于 messages[0 : 当前 assistant 的 idx]。下面 38 行就是这个全量历史,按时间顺序展开。

12.3 一次完整 LLM 调用的剖面 — 看清"输入是什么 / 输出是什么 / Agent 干了什么"

选一个 iteration,下面 4 个 panel 把这一轮拆成 4 步: 📥 模型输入 → 🤖 LLM → 📤 模型输出 → 🔄 Agent 路由 + 拿回工具结果。 默认选了 Iter 4(核心决策:识别异常 → 提交报障)。

12.4 完整运行 log preview

完整文件 data/water_emergency.run.log 共 1095 行。 下面默认展开头 60 行(容器拉起到 bootstrap)和尾 40 行(rubric 评分 + 出分)。中间的 agent 主循环长度太大,请直接在 12.2 看消息流,或下载 log。

▾ 展开 log 头 60 行(启动阶段)
▾ 展开 log 尾 40 行(rubric 评分 + 出分)

12.5 复跑这次结果的方法

想自己跑一次拿到完全独立的 result:

# (在 setup-nexus-aidata SKILL §0–§4 完成基础上)
cd /data/workspace/Nexus-AIDATA
source .env
uv run python nexus/extensions/tasks/aidata/aidata.py run water_emergency -v 2>&1 | tee /tmp/water_emergency.$(date +%s).log
# 跑完日志开头会打印 result 文件路径,长这样:
#   workspace/mcp/tmp/aidata_water_emergency_<新的 ts>.result.json

分数会因模型版本/采样波动;走通主分支(识别异常 → 报障)的概率在当前 prompt 下接近 100%,但 llm_judge 那 2 项的稳定性看模型。