为什么我们没用Agent 框架,自己手搓了一个 Android 测试 Agent
LangGraph、CrewAI、AutoGen、Pydantic AI、DroidAgent —— agent 框架这两年密集涌现。但我们做 Android UI 测试 Agent 时,发现框架的抽象层级和具身场景的需求差了一个量级,agent loop 这层最后还是自己写了。本文讲 5 条让我们必须手搓的抽象缺口,文末有"什么时候该用框架"的明确边界。不是反框架,是抽象层级要匹配。
一、引子:框架的"普世抽象"在具身场景会变累
"几行代码搭一个 agent" 几乎成了 LLM 应用的默认起点 —— LangGraph、CrewAI、AutoGen、Pydantic AI 各占一片山头,YouTube 上每周都有新框架的入门视频。
但有一类场景在这套抽象下越用越累:具身 agent —— UI 测试、浏览器自动化、桌面操作、机器人控制。它们的难点不在 reasoning loop(这部分框架确实做得不错),而在 感知 / 行动 / 记忆 / 验证的工程细节 —— 这些恰好落在框架抽象层之下。
我们做 Smart-AI-Bot(Android UI 测试 Agent)时,最初的计划是复用 droidrun 的 DroidAgent,把它的 ADB 传输层替换成自家反向 WebSocket 就完事 —— 能省下整套 agent loop 的实现。
实际写下去才发现:test case 结构(path / expected / pass / fail)接不进去、强制验证回环接不进去、步骤回放的数据模型对不上 —— 每一个改造点都是"在框架钩子里硬挤一段独立逻辑"。最后还是自己实现了 agent loop。
下面是 5 条最难"绕过"的抽象缺口,每条结尾给一个钩子指向第三篇里对应的具体翻车故事。
二、五条对不上的抽象
1. mark_done 不算完 —— 框架没有"自我复核"的环
LLM agent 标准玩法:模型决定调 done 工具 → 框架接到 tool call → 终止 loop → 任务完成。
我们要的是:模型说完成不算数。
具体流程:模型调 mark_done(status=pass, reason="经验值从 17 增加到 187") → 系统重新截图 → verifier-LLM 拿着 expected + agent_reason + 新截图判定 → 不通过的话把"差距描述"作为新一轮 user message 塞回 messages,loop 继续跑。
这套环的本质是 agent 自我声明的"完成" ≠ 系统接受的"完成"。框架的 tool result 是 terminal 的 —— 返回值给到模型供下一步决策,没有"框架反过来给模型塞一条反驳"的内建抽象。
用 LangGraph 硬上的话,要写:一个 verifier 节点 + 一条 done → verifier 边 + 一条 verifier_failed → agent 自循环边 + 自定义 state schema 携带 verifier_gap 字段。三个抽象拼起来,你最后会问自己:那我用框架的意义是什么?
更麻烦的是:verifier 喂的不是单张截图,是动作瞬间帧(A)+ 沉淀帧(B)的拼接图。框架要在 verifier 节点内部再嵌一段图像处理 + 多模态调用 —— 又是一层"被框架包了一层、但实际逻辑全是自己写"的代码。
钩子句:双截图为什么是 A + B 而不是 single shot,见第三篇坑 1。
2. 多源记忆按固定顺序拼接 —— messages: List 抽象太粗
LangChain 风格的 memory 抽象通常是 messages: List[Message] + 一种 strategy(buffer / summary / window)。够用的场景:chat 类 agent,对话历史是单一来源。
我们的 agent 每步要往 context 注入 6 类异质信息,顺序固定:
[Agent Notes] ← remember() 工具写入的笔记,永不截断
[History Summary] ← 每 4 步 LLM 摘要替代硬截断的旧消息
[Previous State] ← 上一步 a11y 树(让模型对比"我点完之后发生了什么")
[Action History] ← 最近 5 条工具调用
[Current State] ← 当前 a11y 树
当前标注截图 ← 含 SoM 标注、半尺寸
外加 Pinned(system prompt + goal + reference 示例 + plan + lessons)和跨次记忆:从数据库加载同一 case 上次 starred 成功运行的 action_history 作为软参考,再从 LessonLearned 表加载负面经验作为 "AVOID these mistakes" pinned message。
每一类有自己的截断 / 压缩 / 去重规则:
- 旧消息每 4 步触发一次 LLM 摘要替代硬截断
- 旧消息里的 image_url 要剔除(防 context 爆炸),text 保留
- Pinned 完全不参与截断
- 卡住时还要按
recovery_level注入对应 WARNING 文本
框架的 messages 抽象 = list + 一个 strategy。把上面这套塞进去 = 用框架的语法把框架重写一遍。"几行代码搭 agent" 的承诺,在第三类记忆出现时就破了。
3. 感知流水线要逐像素控制 —— 框架不暴露"截图前后处理"的钩子
视觉 agent 的"看"远不是 "把截图传给 GPT-4V"。我们的感知流水线:
原始截图 (1080×2400)
→ 缩到 50% (540×1200) ← AI 在这张图上给像素坐标
→ 叠坐标网格 (每 20% 一条线, 边缘印真实像素值)
→ 叠 SoM 品红十字 (每个 a11y 元素中心一个编号)
→ base64 + 喂模型
← AI 输出 tap(x=270, y=600)
→ ×2 反算得 (540, 1200) 设备坐标
→ 下发 Portal
框架的 tool_call 接口长这样:你声明 tap(x, y),框架接收模型的调用、转发给你的 handler。中间这条"截图怎么标注、坐标怎么换算"的流水线,没有钩子。
更细的一层:工具 schema 里 description 字段写 "PREFER tap_element over tap when an a11y index is available" 会显著影响模型选择 —— 这是 prompt engineering 的延伸。但框架通常把工具 schema 当静态配置,不鼓励你逐字调描述、不鼓励你按上下文动态切换工具顺序。
还有 fallback 路径:a11y 树为空时(Canvas / 游戏 / WebView),要切到纯视觉的 VLM 检测路径,返回的虚拟元素列表要和 a11y 解析器格式一致 —— 才能继续走 SoM 标注 + tap_element(index)。这是个内部状态机,框架的 "tool A 失败 → 调 tool B" 抽象塞不下。
钩子句:SoM 标记为什么从蓝色填充圆换成品红十字(被模型当成游戏内品狂点),见第三篇坑 3。
4. 渐衰式恢复 —— retry decorator 做不到
Agent 卡住怎么办?框架的标准答案:retry decorator + 重试预算 + 最终放弃。
但 LLM agent 的"卡住"比传统程序更隐蔽:模型在错误页面上自信地点同一个按钮 8 次,每次都觉得这次会响应。retry 在工具调用层数不出"卡住" —— 工具每次都成功返回了,只是没产生预期效果。
我们的做法是 4 级渐衰恢复:
recovery_level = 0 正常
↓ 检测到 stuck pattern
1 context 注入 ⚠ WARNING 文字提示
↓ 还卡
2 系统自动注入 press_key(back) 工具调用
↓ 还卡
3 系统自动注入 start_app(package) 重启
↓ 还卡
4 force fail,写 LessonLearned
关键设计:渐衰,不是硬重置。 Agent 做一次"不同的动作"只让 level 微降,不让它直接归零 —— 否则它可以靠"做一次无关动作"逃出恢复流程、立刻回到原死路。
这是个连续状态机 + 动态 prompt 注入的混合体。框架的 retry 抽象处理的是离散事件,处理不了"按 level 注入不同提示,level 本身又是个会缓慢衰减的状态变量"。
5. 逐帧回放要 schema 级对齐 —— callback / tracing 颗粒度不对
我们的回放数据是这样一个结构:
StepLog(
step=N,
thought=..., # 该步 LLM 的推理文本
action=..., # 调用的工具+参数
action_result=..., # 工具返回
screenshot_b64=..., # AI 当时看到的那张标注截图
prompt_tokens=...,
completion_tokens=...,
perception_ms=..., # 截图+a11y 耗时
llm_ms=..., # LLM 推理耗时
action_ms=..., # 工具执行耗时
subgoal_index=...,
subgoal_desc=...,
)
每个 case 落 SQLite,前端时间线渲染 + HTML 报告导出 + SSE 实时推送。截图必须是 AI 决策那一刻看到的那张(含 SoM 标注),不是事后补拍 —— 否则 "AI 在想什么 / 看到什么 / 做了什么" 三者就对不齐,回放价值瞬间归零。
框架的 callback / tracing 颗粒度通常是:一次 LLM call 一个事件,一次 tool call 一个事件 —— 事件分散在多个回调里。要把它们组合成一个 StepLog,要么 buffer + 关联 ID 拼接,要么直接 fork 框架。
更核心的论点:这不是"框架有没有这个功能"的问题,是"框架的数据模型 ≠ 产品所需的数据模型"。
回放是 Smart-AI-Bot 的产品功能,不是 agent 的副产品。当数据模型已经是产品本身、agent loop 只是产生这个数据模型的"生产线"时 —— 框架的数据模型如果对不上,你最后是在做适配器、不是用框架。这是手搓和用框架的根本分水岭。
钩子句:为什么连被 verifier 拒掉的步骤也要写 StepLog(不要 under-include),见第三篇坑 5。
三、那什么时候该用框架
写到这你可能觉得我在 dis 框架 —— 其实不是。我们用 LiteLLM 调 LLM、用 Pydantic 描述 schema —— 窄而透明的工具一直在用。手搓的只是 agent loop 这一层。
什么场景该用框架(LangGraph / CrewAI / AutoGen / DroidAgent / ...):
- task 简单、tools 标准化:query DB / 调 API / RAG / 文档问答 / 表单填充 → 框架完胜。这是它们设计的甜区。
- "几行代码起一个 agent" 是真实价值主张:MVP、内部工具、demo、原型 → 用框架,省下来的时间够你迭代两轮业务逻辑。
- agent 主要价值在 reasoning loop:复杂的工具编排、多 agent 协作、规划-执行分层 → LangGraph 的 StateGraph、CrewAI 的 Crew 抽象就是为这个设计的。
什么场景该手搓:
- 具身 / 多模态 / 6 层都要细控
- 数据模型已经是产品的一部分(不是 agent 的副产品)
- 业务逻辑深度嵌入 agent loop(验证回环、跨次记忆、渐衰恢复)
一句话:"框架功能很全" 不如 "框架抽象刚好"。抽象层级和你的需求差一个量级,无论这层有多薄,都会一直让你绕。
四、我们实际的工具栈
具体组合:
- LLM 调用层:LiteLLM —— 一个接口调 OpenAI / Anthropic / Gemini / 智谱 / Groq / Ollama,窄而透明
- Schema:Pydantic —— OpenAI function-call 工具描述、SubGoal、StepLog 全用
- 路由:FastAPI + SSE
- 存储:SQLite(轻量、嵌入式、自托管首选)
- 设备通信:自实现的反向 WebSocket(连接稳定性策略借鉴 droidrun-portal)
Agent loop 本身手写。 "不用 agent 框架" ≠ "什么轮子都不用" —— LLM 调用层 / agent 编排层是两件事,应该分开评估。
五、链接 + 邀请
- 项目:github.com/rejigtian/S…(MIT 开源)
- 完整 Agent 架构:docs/agent-architecture.md(六层运行时 + 9 个设计决策 Q&A)
- 第一篇(why):用 LLM 替掉 Appium:一个 Android 测试 Agent 的工程实践
- 第三篇(5 个具体的工程坑):juejin.cn/post/764441…
如果你正在评估 agent 框架、或者也在做具身 / 多模态 agent,欢迎到 GitHub issue 区交流踩坑经验,PR 也很欢迎。