接口跑通了,我为什么还要打开页面看一眼
接口跑通了,我为什么还要打开页面看一眼
前言
这是「把业务系统接进 Agent」系列的第六篇。前面几篇一直在讲怎么把接口包成工具、怎么管权限、怎么接 MCP。这篇讲一个很容易被跳过、但跳过之后一定出事的问题:接口成功之后,怎么确认事情真的办成了。
先说一次改活动价的经历。
当时我让 Agent 改一个商品的活动价,从 99 改成 79。工具调完,接口返回 200,日志里一串 Success,Agent 也很干脆地回了一句「已经改好了」。结果业务打开商品页一看,还是 99。
查了半天才明白:接口写的是主库,商品页读的是缓存,缓存要过几分钟才失效。接口没错,页面也没错,错的是我们把「接口成功」当成了「事情办成」。
从那以后,我给写操作都加了一步:接口跑完,打开页面看一眼。
一、接口返回 200,只是服务端说它收到了
一次改价,请求大概会经过这些地方:
Agent → 工具层 → 业务服务 → 数据库 → 缓存 → BFF → 前端 → 用户接口的 200,只代表业务服务这一层处理完了。后面的缓存有没有刷新、BFF 有没有拿到新数据、前端有没有重新渲染,它都不知道。
Agent 更不知道。它能看到的只有工具返回的那段结果。工具说成功,它就认为成功;工具返回一个订单对象,它就认为订单变了。它没有眼睛,不会自己去页面确认。
但业务验收的从来不是 HTTP 状态码,是页面上那个结果。用户不会去看你的接口日志,他只看价格对不对、状态对不对、列表里有没有。
所以「接口成功」和「事情办成」之间,隔着一整条链路。这条链路上任何一环出问题,接口都是 200,页面都是错的。
二、页面能看出三类接口看不出的问题
我把这类问题分成三类,都是接口断言测不出来的。
第一类是渲染。接口返回1234.5,页面显示成1234.5还是¥1,234.50;接口返回 20 条,前端分页只渲染了 10 条;字段是空的时候,页面显示null还是一个空白;日期少了一天,可能是时区没处理对。这些都不影响接口返回,但用户一眼就能看见。
第二类是权限和状态。接口按权限过滤了数据,但页面缓存了上一个用户的列表;提交成功了,页面还显示旧状态;异步任务还在跑,页面先显示了「已完成」;前端做了乐观更新,接口其实失败了,页面看起来却是成功的。
第三类是跨系统。订单接口成功了,但履约页面没同步;审批接口通过了,但待办列表里还在;支付成功了,但账单页还是未支付。这类问题最麻烦,因为两个系统各自都对,只有站在用户视角把流程走完,才能发现中间断了一截。
这三类问题有一个共同点:它们都不是「接口错了」,而是「接口对了,但用户看到的不对」。你盯着接口日志查一天,也查不出来。
三、执行走接口,验证走页面
有人听到这里会说,那干脆全用浏览器操作算了。
不行。全用页面操作,慢、脆、贵,而且权限和审计都很难做。第九篇讲浏览器自动化的时候就说过,那是没有 API 时的兜底,不是有 API 时的首选。
我的做法是分开:执行走接口,验证走页面。
执行用领域工具。改价就调update_price,退款就调refund_order。这类工具稳定、快、参数清楚,出了事能审计到具体动作。
验证用浏览器,但只读。打开用户会看的那个页面,检查关键文本、关键元素、关键状态。不点击,不填表,不提交。它只负责回答一个问题:用户现在看到的是不是对的。
验收工具不要做成通用的click/fill。那种工具动作空间太大,模型很容易在验收的时候又改了什么。应该做成窄接口,比如read_page_text、assert_page_contains、assert_element_state。参数是页面地址和断言条件,不是一串自由操作。
权限也要注意。验收必须用当前用户的会话,不能用管理员账号。否则你看到的是管理员视角,用户看不到的东西你都能看到,验收就失去意义了。
四、验收点怎么选
不是每个动作都要配页面验收。全配一遍,成本高,也没必要。
我的判断标准只有一句:这个动作如果失败,用户会在哪个页面发现?那个页面就是验收点。
改价,验收点是商品详情页或活动页。退款,是订单详情页和退款记录页。审批,是待办列表和详情页。发布文章,是前台的文章页。这些页面是流程的最后一公里,用户真正看结果的地方。
读接口可以抽查。比如查询订单,接口返回了什么,页面大概率也显示什么,抽一两个关键字段看看就行。写接口和跨系统流程必须过页面,因为这两类最容易出现「接口成功、页面没变」。
还有一个细节:验收要等。异步任务、缓存刷新、消息同步都需要时间。但不能用固定的sleep,等三秒还是等三十秒?应该用条件轮询,等到页面出现预期状态,或者超时失败。
五、代码怎么写
我把验收拆成三层:断言、验收点、验收工具。
先定义断言。断言只读页面快照,不操作页面。
publicinterfaceAssertion{booleantest(PageSnapshotpage);Stringdescribe();}publicrecordTextContains(Stringtext)implementsAssertion{publicbooleantest(PageSnapshotpage){returnpage.text().contains(text);}publicStringdescribe(){return"页面应包含: "+text;}}publicrecordElementAbsent(Stringselector)implementsAssertion{publicbooleantest(PageSnapshotpage){return!page.exists(selector);}publicStringdescribe(){return"页面不应出现: "+selector;}}然后是验收点。一个动作对应一个页面和一个断言列表。
publicrecordAcceptanceCheck(Stringaction,StringurlTemplate,List<Assertion>assertions){}publicfinalclassAcceptanceRegistry{privatefinalMap<String,AcceptanceCheck>checks;publicAcceptanceCheckget(Stringaction){AcceptanceCheckcheck=checks.get(action);if(check==null){thrownewToolException("没有配置验收点: "+action);}returncheck;}}最后是验收工具。模型只需要说「验一下改价,业务 ID 是多少」,具体打开哪个页面、断言什么,都在注册表里。
publicrecordVerifyInput(@NotBlankStringaction,@NotBlankStringbusinessId){}publicrecordVerifyOutput(booleanpassed,List<String>failures,StringpageSummary){}publicfinalclassVerifyActionToolimplementsAgentTool<VerifyInput,VerifyOutput>{privatefinalAcceptanceRegistryregistry;privatefinalBrowserClientbrowser;@OverridepublicStringname(){return"verify_action";}@OverridepublicStringdescription(){return"只读验收:打开业务页面,检查动作执行后的最终状态。"+"不会点击、不会提交。写操作执行后应调用一次。";}@OverridepublicVerifyOutputexecute(VerifyInputin,ToolContextctx){AcceptanceCheckcheck=registry.get(in.action());Stringurl=check.urlTemplate().replace("{id}",in.businessId());PageSnapshotpage=browser.openReadOnly(url,ctx.sessionId());List<String>failures=check.assertions().stream().filter(a->!a.test(page)).map(Assertion::describe).toList();returnnewVerifyOutput(failures.isEmpty(),failures,page.summary());}}browser.openReadOnly用当前用户会话打开页面,等 DOM 稳定后拍一个快照,不做任何写操作。断言只在这个快照上跑。这样验收和操作是两条独立的路径,不会因为共用同一个缓存或者同一个连接而自欺欺人。
六、Agent 什么时候才算「完成」
加了验收之后,Agent 的完成标准要改。
以前是工具返回成功就算完成。现在应该是:执行成功,并且验收通过,才算完成。
伪代码大概是这样:
result = call_tool(action, params) if not result.ok: return "执行失败: " + result.error verify = verify_action(action, businessId) if not verify.passed: return "执行成功,但页面验收失败: " + verify.failures return "已完成,页面状态已确认"验收失败之后,不要让它盲目重试。先分类:是异步还没跑完,等一会儿再验;是缓存没刷新,换个读路径或者等失效;是权限不对,检查会话;是跨系统没同步,看消息有没有发出去;还是真的改错了,回滚。
分类清楚再动作。最怕的是验收失败之后,模型又调一遍写接口,把已经成功的事情做了第二次。
还有一点:验收失败时,要把证据带回来。页面上看到了什么、哪个断言没过、快照摘要是什么。不要只说「验收失败」。人排查的时候,这些证据比一句失败有用得多。
七、几个我踩过的坑
第一个坑,用接口返回的数据去构造验收断言。接口说价格是 79,你就断言页面应该有 79。这不叫验收,这叫自己证明自己。验收的数据必须来自页面本身,或者来自一条独立的读路径。
第二个坑,验收和操作共用缓存。改完之后马上验,读到的还是旧缓存,怎么验都不过。要么等缓存失效,要么在验收时加一个绕过缓存的读参数。
第三个坑,只验文本,不验状态。页面上有「79」这个数字,不代表价格改对了,可能只是推荐位里的另一个商品。要验具体元素里的值,不是全页搜文本。
第四个坑,验收点选太多。每个动作都配三四个页面,跑一轮下来又慢又贵。选用户最后真正看的那个页面,一个就够。
第五个坑,用管理员账号验收。管理员能看到所有数据,用户看不到的它也能看到,验收全过,用户一打开还是不对。验收必须用当前用户的会话。
八、下一篇
执行和验收都有了,下一篇把这个系列串起来:拿订单查询这个场景,从接口选择、工具封装、权限透传,到最后的页面验收,完整走一遍。
参考资料
- Playwright 断言与等待
- Chrome DevTools Protocol
- 本系列第九篇:浏览器自动化 Agent 详解