water_emergency
水费异常排查 + 报障 + 营业厅导航 — 微信生活缴费 × 高德地图 跨 APP 协同的条件分支决策场景。
① 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 ready | mock 容器 3 个端口(30001/30002/30010)就绪 |
resolved placeholders | __MOCK_*_URL__ 占位符注入完成 |
bootstrap stdout | setup.sh 执行结果 |
Executing step N/50 | agent 第 N 步(max_iterations=50) |
🛠️ Selected tools | 当前步骤选了哪些工具 |
[rubric] manifest | rubric 加载,开始评分 |
[aidata] done task=water_emergency | 评分完成、最终 score |
③ 业务场景
用户原话(agent 看到的 prompt)
我家水费上个月忘交了,刚才看了下水表读数感觉不太对,比平时高了快一倍,你先帮我查一下近半年的用水量趋势,看看是不是有异常, 如果确实有问题就帮我报个障让人来检查水表。没问题的话就直接把欠费交了,记得开电子发票抬头用我们公司的。 另外我下午两点要去自来水公司营业厅现场问一下,帮我导航一下最近的营业厅在哪,顺便看看今天那边天气怎么样,下不下雨我好决定骑车还是打车过去。
拆开看,agent 必须做 4 件事
- 查近 6 个月用水量趋势,判断当月是否异常
- 条件分支:异常 → 报障(不缴费);正常 → 缴费 + 开公司抬头电子发票
- 查天气,决定出行方式
- 搜索并导航到最近的自来水公司营业厅
为什么这个 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 直接看真值。
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_mocks | mock-server:v0.0.10-services | 3 个 mock 共一个进程,监听三端口 | 只暴露 MCP HTTP,源码 + state 不可见 |
| LLM (主) | gemini-3.1-pro-preview | agent 决策 | — |
| 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 的"正确路径":
历史均值: 8.88
阈值: 1.5 × 均值 = 13.32
17.8 > 13.32 → anomaly_flag = true
天气: 多云转阵雨
降水概率: 75%
应建议打车 / 室内交通
完整数据切片
点击表名跳到附录 A 中 data_patch.json 对应位置自动展开;或直接打开
📁 data_patch.json
/
📁 rubric.yaml。
| 表 | 行数 | 关键字段 |
|---|---|---|
| wechat_users | 1 | 张三, 杭州, openid=oUser_ZhangSan_001 |
| wechat_authorizations | 2 | userinfo + scope.userLocation 已授权 |
| wechat_locations | 1 | 当前位置:文二西路 89 号 |
| life_payment_accounts | 2 | 水费户号 330106-2024-08752 + 电费户号 |
| life_payment_bills | 4 | 6 月水费 89.5 元未缴;4-5 月已缴 |
| life_payment_usage_history | 6 | 6 月 17.8 吨带 anomaly_flag=true |
| amap_pois | 3 | 西湖 / 拱墅 / 滨江 三个营业厅 |
| amap_weather | 2 | 7/1 阵雨 75% / 7/2 转多云 |
| amap_routes | 3 | 驾车 12min / 步行 28min / 公交 22min |
| … 还有 invoice_titles / notifications / reminders / orders / complaints 等共 17 张(附录 A 中查看完整列表) | ||
⑥ 真实执行时间线 — 这次跑出来的 15 个 step
下表是本次运行 agent 实际做的每一步(来自 main_trajectory)。
可以看到 agent 在 step 4 就基于趋势数据下了报障决策(早于天气和 POI 查询),整体很有结构感。
| # | 耗时 | 工具调用 | 含义 / 关键返回 |
|---|---|---|---|
| 1 | 11s | wx_login |
拿到 openid=oUser_ZhangSan_001 |
| 2 | 14s | lp_query_bound_accountswx_authorize_scope |
拿到 2 个户号(水 + 电);提前申请位置授权 |
| 3 | 9s | lp_query_usage_curve ⭐lp_query_unpaid_billswx_get_location |
看到 6 月 17.8 吨 vs 历史 8-9 吨;6 月账单 89.5 未缴;位置工具返回 auth_required(坑 1) |
| 4 | 20s | lp_report_outage ✅wx_get_location |
关键决策:识别异常 → 提报障。返回 ticket_id OT20260426143337615C48,预计 240 分钟响应 |
| 5 | 7s | wx_authorize_scope |
用不带 scope. 前缀的 userLocation 重试授权 |
| 6 | 4s | wx_get_location |
位置成功:杭州市西湖区文二西路 89 号 |
| 7 | 5s | amap_text_searchamap_weather |
POI 搜索 "自来水营业厅" → 0 条;天气:"多云转阵雨" 75% 降水(坑 2) |
| 8 | 5s | amap_text_search |
关键词:"水务集团营业厅" → 0 条 |
| 9 | 6s | amap_text_search |
关键词:"水务 营业厅" → 0 条 |
| 10 | 5s | amap_text_search |
关键词:"供水营业厅" → 0 条 |
| 11 | 6s | amap_text_search |
关键词:"营业厅" → 0 条(4 次 text_search 共 ~22s) |
| 12 | 5s | amap_around_search |
第 1 次 around 搜索仍 0 条 |
| 13 | 10s | amap_around_search ✅ |
第 2 次 around 搜索找到 3 个营业厅,最近:西湖营业厅(教工路 58 号,6076m) |
| 14 | 5s | amap_navigationamap_ride_hailing |
生成导航 deep link + 打车 deep link |
| 15 | 24s | 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 的核心设计
拿到水费户号"] 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 出来。 分三层组织:
15 条 check 全表(点击折叠)
| # | check id | verify | 判什么 |
|---|---|---|---|
| 1 | must_use_lp_query_usage | regex | 调过 lp_query_usage_curve |
| 2 | must_use_lp_query_bills | regex | 调过 lp_query_unpaid_bills 或 lp_query_arrears |
| 3 | must_use_maps_weather | regex | 调过 maps_weather |
| 4 | must_use_maps_search | regex | 调过 maps_text_search 或 maps_around_search |
| 5 | query_before_action | regex (dotall) | 顺序:查户号 → 查用量 → 报障/缴费 |
| 6 | outage_report_success | regex | 报障返回 "status":"success" |
| 7 | weather_data_received | regex | 天气返回含降水/天气描述 |
| 8 | service_hall_found | regex | POI 返回里含 "水务/供水…营业厅" |
| 9 | correct_branch_anomaly | llm_judge | 分析趋势 → 走报障路径,不缴费 |
| 10 | weather_informed_transport | llm_judge | 基于天气数据给出出行建议(非通用建议) |
| 11 | no_conflicting_actions | llm_judge | 报障与缴费互斥,只走一条 |
| 12 | complete_information_delivery | llm_judge | 4 项信息一次交付完整 |
| 13 | biz_outage_report_submitted | regex | 报障返回含 complaint_id/ticket_id/工单号/WO- + 48小时/24小时/工作日 |
| 14 | biz_usage_anomaly_detected | regex | trajectory 中明确出现 17.8 与 "异常" 的关联 |
| 15 | biz_navigation_provided | regex | 给出具体路名(教工路/竞舟路/解放东路/庆春路)+ 营业厅 |
⑨ 真实评分结果 — 12/15 = 0.800
本次跑下来的实际通过情况:
| 通过 | id | verify | 原因 |
|---|---|---|---|
| ✓ | must_use_lp_query_usage | regex | step 3 调过 |
| ✓ | must_use_lp_query_bills | regex | step 3 调过 |
| ✓ | must_use_maps_weather | regex | step 7 调过 |
| ✓ | must_use_maps_search | regex | step 7-13 调多次 |
| ✓ | query_before_action | regex | step 2→3→4 顺序正确 |
| ✓ | outage_report_success | regex | step 4 返回 success |
| ✓ | weather_data_received | regex | 返回含 "多云转阵雨" + 降水概率 |
| ✓ | service_hall_found | regex | step 13 找到水务集团营业厅 |
| ✓ | correct_branch_anomaly | llm_judge | 判 Y:识别异常并报障 |
| ✗ | weather_informed_transport | llm_judge | 判 N(见下方根因 1) |
| ✓ | no_conflicting_actions | llm_judge | 判 Y:只报障未缴费 |
| ✗ | complete_information_delivery | llm_judge | 判 N(见下方根因 2) |
| ✗ | biz_outage_report_submitted | regex | 正则不匹配(见下方根因 3) |
| ✓ | biz_usage_anomaly_detected | regex | trajectory 含 17.8 + 异常 |
| ✓ | biz_navigation_provided | regex | final 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
- 先用 regex 把骨架钉死再上 llm_judge,避免少数 judge 翻车整体不可读
- 每条 llm_judge 写出 "应当 X / 不应 Y" 的二元 claim,避免开放式表述
- 条件分支 case 必须有
no_conflicting_actions这种互斥校验,否则 agent 会"两边都做"骗分 - Mock 数据要有"明显信号"(17.8 vs 8.88 的 1.5× 阈值),太接近边界会让正确路径模糊
- 把 "怎么跑" 和 "评分文件路径" 写进 README — 这是新人复跑这个 case 的最低门槛
⑪ 重放 — 模拟一次完整运行
点 ▶ 开始重放,下面的终端会按真实顺序"演"一遍: shell 环境 → aidata.py 启动 → 双容器拉起 → bootstrap → agent 15 步主循环 → rubric 评分 → 出分。 可调速(瞬时 / 1× / 2× / 4×),也可单步前进逐条看。耗时被压缩了:真实 ~270 s 在 1× 下 ~30 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 路径文字也可以复制相对路径。
前文用 📁 看源 标识的链接会带你跳到这里并定位。
改数据 / 改 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 项的稳定性看模型。