通过复现Agent在网络中断时的行为,验证其fail-closed特性,避免在真实用户场景中出现代码丢失或静默失败。
你在 Pixel 8a(Android 15)前坐下,签名过的 staging build 已放在主屏幕上。聊天界面还残留着半个未完成的 agent 对话轮次,工具调用转圈在两秒后卡住了。趁 HTTP 响应尚未到达,你开启飞行模式,然后在设备锁定三十秒后解锁。这个生命周期跳转是你首先要复现的东西,因为生成的 agent 代码通常假设无线电永远不会断开。
这是一个提议性测试,而非来自私有实验室的测量结果,不应盲目照搬。你应该在自己的手机上运行这些步骤,然后记下该轮次是恢复了、重启了,还是静默消失了。2026 年加入移动 AI 仓库的初级工程师第一天就会接触到 agent 脚手架,而生成这套脚手架的成本很低。真正昂贵的是证明:当蜂窝网络在一个进行中的工具调用中途消失时,agent 会 fail closed。
你在第一个小时内要证明的东西
你要证明的不是模型在面向投资人的走廊演示中听起来有多聪明。你要证明的是三个枯燥的属性,任何审查者无需 staging VPN 就能重新运行。在触碰任何 prompt 文件或工具映射之前,先把这三条写在工单上。
进行中的工具调用有 generationId 和 configHash,不只是一个转圈。
飞行模式加上锁屏不能产生由过期 schema 触发的后续重试。
配置回滚不能复活由前一个 agent bundle 产生的队列中的工作。
如果你的仓库已交付了设备端运行时,在第一个小时内保持这条路径在循环中。远程模型只是一个预言机(oracle),负责提供 JSON 结构、错误码和 fail-closed 消息。不要把预言机当作首发产品的 Shipping 路径来对待。
切换无线电之前先记录环境
在开启飞行模式之前捕获版本和无线电状态,否则一周后你的笔记就毫无用处。用这份清单作为 PR 的头部,这样审查者才能重复这个跳转。跳过任何一行,实验就无法在不同手机之间做对比了。
设备和 OS:Pixel 8a + Android 15,或你的 iPhone 及精确的 iOS build。
应用状态:已登录,聊天可见,工具调用进行中,屏幕亮起,然后锁定。
框架 pinned 版本:React Native、NetInfo、HTTP 客户端,以及设备端或边缘运行时。
网络和电源:Wi-Fi 对比 LTE、节电模式关闭、无 VPN、无私有 DNS 干扰。
权限:只有该工具确实需要时才申请位置或麦克风;否则保持拒绝状态。
预期观察:用户可见的暂停状态、无重复副作用、无缺失的对话记录。
如果你说不出执行工具的运行时是什么,你还没准备好去改 prompt。在第一个小时的剩余时间里,用 grep 搜索 AsyncStorage、WorkManager、URLSession 和任何重试拦截器。在这四样东西上多花时间,因为它们通常藏有在回滚之后才会显现的静默重复。
提议实验:一部设备,一次跳转
在物理设备而非模拟器上执行这些步骤,模拟器的无线电是假的。模拟器会谎报无线电丢失,以及锁屏后进程死亡的情况。保持聊天界面在屏幕上,这样生命周期从进行中的工具调用开始,而非冷启动。
安装 staging build,用一个一次性账号打开新对话。
发送一个强制触发网络工具的 prompt,比如商店查询,而非本地重写。
当转圈出现时,一秒内开启飞行模式,然后锁屏。
等待三十秒不要偷看。解锁后截图,记录 transcript、横幅和日志。
关闭飞行模式,等待无线电恢复,观察是否有你未请求的静默重试。
强制停止应用,重新启动,检查排队的工具调用是否会自行触发。
记录一种结果:用相同的 generationId 恢复、作为新轮次重启、或消失了。
你应该预期一个可见的暂停或 fail-closed 状态,而非无线电恢复后再来一次商店查询。消失的转圈加上后续重复的副作用是一个失败结果,即使文字看起来没问题。在日志中附上显示相同 generationId 跨越锁屏前后的 logcat 或 Console.app 行,因为截图本身在审查中站不住脚。
你的第一个 PR 应该交付的状态机
保持第一个 pull request 足够小,以便在当天下午一次 commit 就能回滚。把对话轮次建模为状态机,然后在任何 HTTP 写操作之前持久化(generationId、configHash、state)。如果你无法持久化这个元组,就无法后续证明回滚安全性。
idle — 无工作,无队列
tool_pending — 请求已在传输中
queued_offline — 仅在工具是幂等的且用户选择重试时才成立
cancelled — fail closed 并展示一个暂停 chip
rolled_back — 配置哈希不再匹配,因此丢弃队列
默认行为:飞行模式在 tool_pending 期间触发时转向 cancelled。queued_offline 是一个明确的产品决策,而非 HTTP 客户端的默认礼貌。初级工程师容易把这两者搞反,因为拦截器默认会重试,而生成的胶水代码会把这种习惯复制过去。
提议的 React Native 草稿
// Proposed example, not production code. Pin versions in the PR header.
import NetInfo from '@react-native-community/netinfo';
import { AppState } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
type AgentState =
| 'idle'
| 'tool_pending'
| 'queued_offline'
| 'cancelled'
| 'rolled_back';
type TurnRecord = {
generationId: string;
configHash: string;
state: AgentState;
createdAt: number;
};
const TURN_KEY = 'agent.turn.v1';
export async function persistTurn(record: TurnRecord): Promise<void> {
await AsyncStorage.setItem(TURN_KEY, JSON.stringify(record));
}
export async function failClosedIfStale(
currentHash: string
): Promise<TurnRecord | null> {
const raw = await AsyncStorage.getItem(TURN_KEY);
if (!raw) return null;
const turn = JSON.parse(raw) as TurnRecord;
if (turn.configHash !== currentHash) {
const rolled: TurnRecord = { ...turn, state: 'rolled_back' };
await persistTurn(rolled);
return rolled;
}
return turn;
}
export function watchRadio(onDrop: () => void): () => void {
const unsubNet = NetInfo.addEventListener((state) => {
if (!state.isConnected || state.isInternetReachable === false) {
onDrop();
}
});
const unsubApp = AppState.addEventListener('change', (next) => {
if (next !== 'active') {
onDrop();
}
});
return () => {
unsubNet();
unsubApp.remove();
};
}
把 onDrop 接入,使得 tool_pending 转向 cancelled,并绘制一个用户可读的暂停 chip。不要在 NetInfo 监听器中当无线电几秒后恢复时再次调用 fetch。那个监听器正是静默重复的来源,审查者应该拒绝任何从连接回调触发的重试 PR。
在生产凭证存在之前先做工具 JSON 的契约测试
你的第一个小时还应该把工具 schema 冻结在客户端旁边,而非放在聊天截图里。保存一份 golden request 和一份 golden response,然后断言名称、必填字段和错误结构。把文字质量排除在这个测试之外,因为措辞会漂移而契约不能。
在没有生产凭证的情况下,一个实用的对比预言机是 MonkeyCode 的免费模型访问及其免费服务器选项。披露:本文是 MonkeyCode 产品推广的一部分。让预言机指向你的设备端或边缘路径必须输出的相同 JSON schema,并把用户对话记录留在这个盒子之外。在第一个小时架设预言机,一旦契约测试保持绿色就删除实例。
// Proposed Jest contract. Replace ORACLE_URL with your comparison endpoint.
type ToolCall = { name: string; args: Record<string, unknown> };
export function assertToolShape(call: ToolCall): void {
const allowed = new Set(['lookupStore', 'cancelTurn']);
if (!allowed.has(call.name)) {
throw new Error(`unexpected tool ${call.name}`);
}
if (call.name === 'lookupStore' && typeof call.args.query !== 'string') {
throw new Error('lookupStore.query must be a string');
}
}
test('oracle tool call matches the mobile contract', async () => {
const res = await fetch(process.env.ORACLE_URL!, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
prompt: 'find a store',
schemaVersion: 'store-lookup-v1',
}),
});
const body = await res.json();
assertToolShape(body.toolCall);
});
如果预言机和手机在工具名称上不一致,在任何人争论延迟之前就让 PR 失败。Schema 漂移在 Jest 中捕获比在商店列表中捕获要便宜,后者会导致预订重复。在 CI 中对 golden 文件做哈希,这样 prompt 编辑就无法在不触发契约更新的情况下合并。
第一次回滚且不复活队列中的工作
回滚是第三个第一个小时练习,大多数团队会跳过它直到一个糟糕的 prompt 流出。你回滚 agent 配置或 prompt bundle,重新安装之前的二进制文件,然后在同一个对话中启动。问题不在于旧版本能否构建,而在于昨天的队列是否仍然会触发。
# Proposed checks after you reinstall the rolled-back build.
adb shell dumpsys package com.example.staging | grep versionName
adb exec-out run-as com.example.staging cat files/agent.turn.v1
adb logcat -d | grep generationId
在 iOS 上,从应用容器中导出相同记录,在恢复后查找 configHash。如果新配置的哈希仍然坐在存储中,下次进入前台时将尝试用旧二进制不理解的工具。你的 PR 应该把哈希不匹配视为 rolled_back,并显示一行系统消息,而不是假装对话轮次已完成而重建一个答案。
审查用的决策表
如果你的工单中缺少某个单元格,审查者应该直接打回 PR,而不要争论模型质量。生成的 agent 胶水最喜欢"直接重试"这个空白单元格,而那个单元格正是初级工程师第一次回滚产生重复副作用的方式。把这张表填在描述里,然后贴上与你声称处理的每行相对应的截图。
同款元组在 Native 和 Flutter 中的注意事项
同样的元组放在 Android 的 DataStore 中,或者在你已经有 Room 的情况下放一小行 Room 记录。在 iOS 上,把 generationId 和 configHash 放在 application support 目录中,而非可能被我备份工具扫到的 defaults 文件中。Flutter 团队可以用 shared_preferences 存储该记录,只要你记住这个插件不是队列也不是锁。
WorkManager 和 BGTaskScheduler 用于选择加入的幂等工具,而非用于在飞行模式后复活已取消的 agent 对话轮次。如果原生重试 worker 已存在,你的第一个 PR 应该让它在触碰网络之前先读取状态。否则跨平台客户端会 fail closed,而平台 worker 悄悄完成了那个过时的调用。
局限性,以及谁不应该使用本文
本文是一份提议的入职工作流,而非关于任何手机电池曲线或 token 吞吐量的声明。本文不附上实验室数据,你也不应该把别人的毫秒级数据复制到你的审查中。NetInfo isInternetReachable 可能滞后于调制解调器,iOS 会以 Android 不会的方式冻结监听器。
不要向对比端点发送生产流量、付费用户对话、精确位置、音频或账户 token。已有锁定 staging mesh 的团队已经有了一个预言机,所以不要为了新奇再添一个。语音中断、Doze 完成和 prompt 缓存备份是单独的练习,本工作流不能替代它们。
如果你的产品在用户确认后法律上必须完成一个支付工具,那就跳过 fail-closed 取消。在这种情况下,你需要在记录服务器上有一个幂等键,这是另一场审查,有不同的失败语言。初级工程师仍然应该持久化 generationId,但下一个状态是带明确恢复的 queued_offline,绝不是隐藏的拦截器重试。
问你的设备,然后告诉我们发生了什么
本周在你的 build 上运行飞行模式锁屏测试,并把笔记放在 PR 旁边。留言说明设备、OS、框架 pinned 版本,以及你使用的精确跳转。说清楚对话轮次是恢复了、重启了,还是回滚后静默消失了,因为那一个词就是下一个初级工程师需要的证据。