跳转到内容

引擎内部

内置 agent 跑在一个小而严格的循环上:模型挑工具调用,引擎运行它们,把结果喂回去,再问一遍 —— 一遍又一遍,直到会话自己关闭。本页其余的一切,都是让那个循环能在一台真实手机上撑过一次跑的规则。这是本套文档里最内部的一页;用 PhysiClaw 不需要懂这些,但如果你是那种想知道 agent 为什么这样行事的搭建者,喏,都在这儿了。

一个会话就是一次醒来。引擎搭好一个系统提示和一条开场消息(唤醒它的那个 trigger),然后转起这个循环:

┌──────────────────────────────────────────────────────┐
│ │
▼ │
MODEL ─────► TOOL_CALLS ─────► DISPATCH ─────► TOOL_RESULTS
picks tools validated + run each tool appended to
for this shape-checked (MCP or local) the transcript
turn │
▲ │
└───────────────────── ask again ──────────────────┘

没有什么隐藏的编排脚本。模型决定做什么;引擎的活儿是安全地执行那些决定、并交回诚实的结果。一个失败的工具会作为一条模型能读、能反应的错误结果回来 —— 绝不是一次崩溃。transcript 始终对 provider 的 API 保持合法:每个工具调用都恰好配一个匹配的结果,就在紧接的下一条消息里。

这是一个原生工具调用循环 —— 结构骑在 provider 真实的 tools=[…] API 和模型真实的 tool_calls 上,而不是靠模型把 JSON 手写进散文、再让引擎去解析。这个区别,是整套设计的脊梁。

循环不是因为撞上某个外部计时器才停。它是在模型判定活儿干完时停下,自己关闭会话,靠调用 end_session(status, recap)status 是五个 sentinel 单词之一,这个选择带着真实的后果:

Status含义接下来发生什么
DONE任务完成。会话结束。终结。
FAIL任务办不到 —— 售罄、账号被锁、违反规矩。会话结束。终结。
WAIT需要人来回复;agent 已停止在会话内等待。会话结束,然后排一个后续(见下文)。
IDLE没什么可做 —— 这次唤醒是空响,没有新消息。会话结束。终结。
STUCK任务中途不可恢复:手机解不开锁、某个 app 崩了、循环跑飞了。引擎用一个全新会话重试

这里头四个,第一次发生就是终结。STUCK 是那个能得到第二次机会的。

STUCK 是引擎对*“这次尝试栽了”*的说法 —— 它不只在模型这么说时被抛出,还会在一次尝试耗尽回合却没干净收尾、provider 用光了自己的重试、或会话直接崩溃时自动抛出。因为一个卡住的会话往往只是运气不好 —— 摄像头上一道转瞬的反光、一次缓慢的加载 —— 引擎并不放弃。它把整次尝试丢掉,从同一个 trigger 起一个全新会话,总共最多 3 次尝试。一个干净的 DONE/FAIL/WAIT/IDLE 第一次出现就把事情了结;只有 STUCK 会绕回来。

一个 WAIT 意味着 agent 给你发了消息,需要你的回答才能继续,所以一直撑着循环开着只会白费 token。它转而关闭 —— 但一个关闭的会话没法唤醒自己。所以 WAIT 配上一个排定的作业,稍后再来查一次。要是 agent 忘了排一个,引擎会察觉,自动排一个通用的 15 分钟 后续。那个兜底故意做得很笨;正确做法是 agent 自己把检查排在合适的延迟上(快回复就几分钟,等订单确认就几小时),这正是 自主任务 那套流程所做的。

这是最能塑造 agent 行为的那条规则。**每一个回合都必须恰好调用两个工具:一个 note,加上恰好一个其他工具。**不是零个。不是三个。引擎检查每个回合,若形态不对,就驳回它、要模型重发 —— 并点名那个出问题的工具,好让模型重试同一个动作,而不是换上随便哪个碰巧合乎形态的东西。

那条规则里捆着两条各自独立的约束,每条都不白占地方:

恰好一个 note

note(summary=…) 是一行、≤20 词的记录,写明这个回合在做什么、为什么。它是强制的,因为它是一个回合里唯一能挺过压缩的部分 —— 一旦某个旧回合老去出局,它的截图和它那一点都没了,但 note 留着。note 是 agent 对自己推理的永久记忆。

恰好一个动作

把一个回合封顶在一个其他工具上,逼着 agent 一步一步地动 —— 点一下,然后看;绝不盲目地点点点。在一台真实手机上,一次错点可能发出一条消息或下一个订单,“一个动作,然后观察结果”就是全部的安全保障,靠结构强制,而非寄希望。

所以一个健康的回合长这样:[note("opening the cart to check the total"), peek()][note("tapping checkout"), tap([0.41,0.88,0.59,0.94])]。note 在叙述;那个单一动作做那一件事。

一个长任务 —— 一趟跨多屏的生鲜采购 —— 可能要走几十个回合,而每个 peek 都驮着一整张屏幕图。放任不管,transcript 会冲破模型的上下文窗口。引擎用两招把它框住,两招都为了甩掉体量、同时保住决策线索

只有最新的那张屏幕图是活的。一旦来了更新的一次 peekscreenshot,引擎就回头把每张更旧的图剥掉,换成一个小小的 (superseded peek) 存根,再加上那张旧列表里的文本行(那些标签 —— “Checkout”、“¥39” —— 它们作为可重新瞄准的锚点仍然有用;那些编号的图标框没了图就毫无意义,所以被丢掉)。agent 的决策完好无损 —— 它做过的每个 note、每个工具调用 —— 但它不再驮着十张它早已走过的屏幕的冗余照片。只有当前这一视图是完整的。

超过一个回合数阈值后,引擎把最老的那些折进对话顶部附近的几个紧凑槽位里:

  • summaries —— 每个老去出局的回合的 note 行,串成一份滚动的”发生了什么”清单;
  • memory loads —— agent 从 memory.md 或它的日志里拉来的任何东西,逐字保留;
  • skill loads —— 它打开过的任何 SKILL.md 工作流,逐字保留。

note 会被总结;memory 和 skill 加载不会,因为那些是 agent 有意加载的持久参考材料 —— 一行总结替不了它正在遵循的工作流。默认第一次折叠发生在第 30 回合前后,始终保留最近 10 个回合完整不动,此后每 20 个回合再折一次。具体数字按 provider 调过,让折叠落在对每家厂商的提示缓存代价最小的地方。

合起来的效果:一个会话能跑得足够长,长到完成一桩真实的多步任务,而模型看到的仍是一个连贯的故事 —— 近期回合完整、更老的回合熬成那一行要紧的话、持久的加载整块带过来。