MIROFISH·仿真链路·USER-SIMULATOR 源码解读

user‑simulator

虚拟用户仿真 · 规划 / 执行两层架构拆解

把一个语言模型,演成一个真人——不是有求必应的助手,也不是那个被 LLM 默认拉平的 「积极的平均人」。这条链路的全部工程,都在对抗这一种坍缩:让沉默、放弃、挑剔与异质性, 真的发生。

命题 (persona + history + observation) → behavior 核心提交 198840a · c0ac460 群体实验 大圆镜 × 8 persona
§ 00 — 第一性

一个足够像人
虚拟用户

engine 的全部立身之本,是一支会决策的箭头。不像人,则上层(场景泛化、产品评测)一切失效。

persona鲜明画像 + history因果轨迹 + observation多模态观察 behavior

没有显式的 goal 字段。目标隐式涌现于自由文本的内心独白与历史之中——显式化会让仿真 变机械、变 task-driven、变得不像人。认知链就是 history 本身,默认不压缩: summary 与 RAG 会破坏因果结构,得不偿失。

数据不可变。每一步产生一个全新的 SessionFramehistory 只增不改——利于回放、 分叉与审计。UserSimulatorAgent 是其上的异步 OO 外壳;fork 只是「截断 + 换 id」 的纯数据操作。一个实例 = 一条逻辑时间线;并发只发生在不同实例之间

§ 01 — 总览

规划 / 执行
两层拆分

规划层(纯认知)只决定用户做什么,唯一对世界的动作是 operate执行层接到语义意图后自己落地。两层都在 engine,simulator 只提供活体沙箱。依赖单向:backend → engine → simulator

规划层 · ENGINE 纯认知 · gpt-5.5 UserSimulatorAgent inference.decide · ReAct · 不可变 frame · 词表 {operate, speak, done, give_up} operate(command) 唯一「对世界动作」· 语义意图 执行层 · ENGINE Executor · 按端形态选实现 视觉端 · ExecutionAgent 会推理的接地子 agent(stepfun 认图) 看屏 → 调工具 → 再看 … 多步接地 mobile · desktop · browser shell · DirectShellExecutor 无 LLM 直通(无接地鸿沟) operate.command = 字面命令 gpt-5.5 亲自写命令读输出 真实端 · SIMULATOR mobile · browser · desktop · shell — 端原语封在执行层 Driver observation 回灌
FIG.01 — 规划层只发 operate 语义意图,执行层按端形态选实现(视觉端接地子 agent / shell 直通),落地真实端后观察回灌闭合因果链

A 执行层是「手」,不是第二个大脑

认知、偏好、决定下一步 100% 留规划层;执行层只把一个客观意图落地。边界压在价值判断上——operate 明令执行层只做客观达成条件,「觉得好不好 / 值不值」必须回规划层自己定。单 agent 认知未被拆分,只是具身被委托。

B uses 单一归属执行层

外部注入的高层能力(write_file / 业务动作)只由执行层感知,规划层永不感知——一个能力恰好一层,杜绝「两层都能调」的双路径(模型摇摆、cost 重影、职责不清)。规划层只发 operate(意图)

§ 02 — 规划层内核

ReAct:观察 → 思考 → 行动

每一步都是一次 decide():把 (persona + history + observation + runtime) 经 user-model 推理成 (thought + actions),连同该步 cost,追加一条 StepRecord

open() runtime.init → 首屏 step() decide → thought + actions append StepRecord(只增不改) execute() 跑 use → 下一步观察 沉默 → observe 兜因果链 actions observation done → succeeded give_up → abandoned ≥ max_turns → timeout
FIG.02 — run() 自治循环:open 开场 → step/execute 闭环 → 三个控制出口收束
engine/agent/agent.py— run() 自治循环
# 开场一等环节:跑 runtime.init 得首步观察,幂等(只开场一次)
observation = await self.open()
while True:
    await self.step(observation)              # 推理一步,追加不可变 StepRecord
    record = self._frame.current
    verdict = _control_verdict(record.actions)
    if verdict == "done":     return await self.end("succeeded")
    if verdict == "give_up":  return await self.end("abandoned")
    if len(self._frame.history) >= max_turns:
        return await self.end("timeout")
    observation = await self.execute(record.actions)  # operate→执行层 → 下一步观察

推理走 tool-calling:把规划层词表——控制动作(speak / done / give_up) 加上挂端时的一等 operate——以工具定义 bind 给模型,模型每步产出一句内心独白(content = thought)+ 一个 tool_call(= action)。 关键在于 decide 只取 tool_calls,不在模型循环里执行——执行交给 agent.execute 与上层调度器,保 decide 纯数据、frame 可 fork、多 agent super-step 可控。规划层不暴露任何 use(外部能力归执行层)。

§ 03 — commit 198840a

跨模型鲁棒性:
四道防线

不同 provider / proxy 各有怪癖——reasoning 藏思考、把动作写进文本、并行调用走幻觉命名空间。decide() 用有界的修复策略兜住,绝不让 agent 陷入「以为做过」的幻觉链。

1

瞬时错误退避重试 _ainvoke_with_retry

rate-limit / 429 / timeout / temporarily 类瞬时错误:最多三次、0.5×(i+1) 退避重试;非瞬时错误立即抛出,不空转。

2

整步失败静默,而非杜撰 except → thought="", actions=[]

LLM 调用 / 工具解析彻底失败时,退化为「沉默一步」:不炸整条会话,也不编造一个动作。沉默是合法的稀疏默认,宁可什么都没做,也不污染因果链。

3

broken-ReAct 修复重试 _intent_in_text

零工具调用、但独白里写了「工具名(…)」——模型在模仿轨迹格式而非真行动(实测 gpt-4o 系高发)。带上原输出 + 修复提示做一次有界重试(仅一次,不循环)。

4

空独白修复 _elicit_monologue

reasoning 模型把思考藏了、content 为空但有 actions:就这步动作向它要一句内心独白(= 模型自己的话,非代码杜撰),保住对外的「人味」。补不到才告警。

engine/agent/inference.py— _flatten_tool_calls · tool_call 解包
# OpenAI 系两个已知怪癖(gpt-4o 群体 e2e 高发):不解开则 actions 落空
for tc in tool_calls:
    name, args = tc["name"], tc.get("args") or {}
    # ① 并行调用走幻觉命名空间 multi_tool_use.parallel,真调用藏在 args.tool_uses
    if name.split(".")[-1] == "parallel" and isinstance(args.get("tool_uses"), list):
        for tu in args["tool_uses"]:
            inner = str(tu.get("recipient_name", "")).split(".")[-1]
            if inner: actions.append(Action(type=inner, args=tu.get("parameters") or {}))
        continue
    # ② 工具名带 functions. 前缀
    actions.append(Action(type=name.removeprefix("functions."), args=args))
§ 04 — prompt.py

喂给模型的,
一个人的处境

system 只讲「你是谁 / 处境 / 怎么结束 / 怎么行动」,能力细节交给 tool 定义。三处工程把 token、视觉与人格漂移一并管住。

折叠 早期观察省 token

历史里距今超过两步的,只留「想 / 做」;最近两步才带完整观察。过往截图只留文字占位,当前观察的真图另作 image 块。

配图 上一步状态图

build_messages 把上一步与当前观察的 image Part 作为 image_url 送进 vision 模型——让它能「看 UI」做决策。本地截图仅在发送时即时读为 data-url,不进任何持久化。

重锚 长会话身份重申

注意力随轮次衰减,system 里的人设会被长 history 稀释(persona-drift 实证 8–12 轮显著)。_anchor 在 ≥4 轮时于生成点附近重申「你是谁 + 此刻状态」。

engine/agent/prompt.py— system_prompt · 反偏差写进常态
"你在扮演一个真人(不是 AI 助手),以第一人称做出这个人真实会做的反应。"
# ……persona(profile / skills / memory)/ 处境 / 眼前 ……(无外挂投入度档位)
"原则:保持这个人的个性,别变成『积极的平均人』——平庸就否、该追问追问、"
"该核实核实、该弃就弃;多数真人多数时刻只看不说,沉默是常态不是例外,"
"按这个人的真实频率开口——人设话少,就让大多数步真的沉默。"
# 每步先有个内心活动:像真人脑子里一闪而过的半截念头——往往很短、跳跃、口语,
# 有时甚至什么都没想(那就留空)。别写成条理分明的分析或要点罗列。

实证修正(2026-06,e2e_usa_fidelity 对照实验定):把「稀疏为默认」写进 system 为显式常态后, 活跃度轴从「全员 88–100% 零方差」还原为「潜水党 14% / 话痨 100%」,且任务 / 问卷语境不受抑制,token 反降约 30%。 更进一步,外挂的投入度档位 AgentMode 已被整体删除——认真 / 摸鱼不再由参数钉档,纯由 persona 文本自洽涌现, TriggerRequest 只剩 persona + scenario。少一个会撒谎的旋钮,异质性回到它本该来的地方:人设本身。

§ 05 — 执行层 · commit c0ac460

执行器
按端形态选实现

执行层不是固定的 LLM 子循环。它按端有没有「接地鸿沟」(意图离精确动作隔着像素 / 元素的不确定性),在会推理的接地子 agent无 LLM 直通之间选——对外仍是同一个 operate 动词。

视觉端

ExecutionAgent · 接地子 agent

有接地鸿沟:意图(「把鼠标加进购物车」)要落成 tap / click,得看屏、认元素。执行层是会推理的子 agent,多步落地——大圆镜 8 人走的就是这条

  • 规划层发 operate(意图)
  • EndDriver observe(截图 + 元素清单)
  • do_tool_step 强制单工具调用
  • 执行 → 重新观察 → 续跑

mobile / desktop 用认图模型 stepfun;运动步压缩——8 次 tap = 规划层 1 决策。

shell

DirectShellExecutor · 直通

无接地鸿沟:命令即精确动作,「一条命令」本身就是一个认知决策(工程师就以命令思考)。执行器不挂 LLM,把 operate.command 当字面命令跑一次。

  • 规划层(gpt-5.5)亲笔写命令
  • 跑一次(带超时)
  • 完整 stdout/stderr + exit code 原样回灌
  • 写整段文件用 write_file(绕开 heredoc)

分层逻辑前移到规划层、执行器退化成直通;文本端 operate 的执行 cost = 0

operate 只能说要达成什么,绝不说怎么操作

命令一旦写成「点击 ADD TO CART」「在输入框输入并发送」,执行模型就镜像成裸 Click/Fill 原语(动词对上了),绕开封装好的健壮 use;翻车 → 规划层更不信任 → 命令更像素级(曾退化到给坐标)→ 更勾原语。恶性循环

修后改成意图级命令(「问 Kimi:<原话>」「加购 2 件」),live 实测:procurement 按键措辞归零、裸原语 22→8;Kimi 从「在输入框输入并发送」变「问 Kimi:…」、裸原语 55→0

c0ac460 为视觉端接地子 agent 补的三件事

按端分系统提示

mobile / browser / shell 各自一段 EndDriver.system + hint:手机讲「Tap 用编号、误入弹窗先关闭」,浏览器讲「Click/Fill 只用 ref、读正文优先 ReadPage」,终端讲「看 exit code、别套 GUI 规则」。

阅读进度感知

mobile observe 把已向下翻几屏、本屏新增可读文本几条、是否出现结尾 / 评论 / 推荐信号、下滑后文字是否重复显式喂回模型——治「长文还没读就声称读完」与「到底了还在傻翻」。

失败摘要人味化

回给规划层 persona 的不是执行层术语:_humanized_failure 把「达到最大步数」翻成「我往下读了 5 屏,还没看到明确结尾」——执行明细藏起来,规划层只见一句人话结论。

engine/runtime/exec.py— MobileDriver.observe · 阅读进度
progress = (
    f"阅读进度(本次执行):已向下翻 {self._down_swipes} 屏;"
    f"本屏新增可读文本 {len(new_terms)} 条;"
    f"{'出现结尾/评论/推荐信号' if end_signal else '未见明确结尾信号'}"
    f"{';下滑后文字重复,可能到底' if repeated_after_down else ''}。"
)
# _READ_END_MARKERS = ("评论区", "相关推荐", "阅读原文", "没有更多", "全文完", …)

轨迹 执行层做了什么,看得见

执行层每次 operate 的逐子步明细(真命令 / 端原语 / use 调用 + 结果 + 截图)经 rt.op_trace 回流,折进 StepRecord.operate_log。这是审计 / 前端显示通道——admin run 详情据此渲染折叠步进时间线、shell 真命令;但不回喂规划层(规划层只见意图摘要,单一归属)。

cost 一帧含两个模型

执行层 token 经 rt.cost_sink 回流,agent.execute 每步并进 StepRecord.cost——一帧同时含规划(gpt-5.5)+ 执行(stepfun / gpt-5.4-mini)两个 model key,按模型名拆分。shell 直通端执行 cost 为 0。

§ 06 — 红线 · 硬约束

别演成
「积极的平均人」

LLM 默认会把用户演成积极的平均人:高估行动频率、回避负面、抹平个性。
  • 稀疏稀疏为默认:多数步 actions=[](真人正向率很低)。别每步都积极行动。
  • 负面负面可表达give_up、抱怨、对抗性 speak 必须演得出。
  • 异质异质性保真:鲜明 persona + 保真 history,保住长尾个性,不收敛成「平均人」。
  • 校准有真实行为数据(L2 场景)时,把该用户真实 base rate 喂入校准。

教训(2026-06 实证):测什么,保什么——没测的轴会塌缩成「积极的平均人」。 三场景全绿的同时,潜水党发言率却到了 75%、话少者 100%。评群体涌现不能替代评个体保真, e2e_world_*(群体)与 e2e_usa_fidelity(个体保真,评 §1 箭头本身)两层都要跑。

§ 07 — 群体实验

大圆镜 · 8 个真人
8 台手机

8 个差异化 persona × mobile 群体实验:同一个「开放刷机」场景,让八种不同的人各自用真实习惯去刷一个科普 App。异质性是这里唯一的主角——投入度不再有外挂档位(AgentMode 已删),认真 / 摸鱼全由 persona 文本自己涌现。

persona · 0
郁桥夜猫剪辑师 · 视频控

进任何产品先找视频入口,片头两秒就判去留;容不得廉价感、填充镜头与营销腔。

emulator-5554
persona · 1
钱姐较真会计 · 慢读避坑

看什么都先防坑,对广告、套路、夸大数字格外警惕;慢、稳、要核实。

emulator-5556
persona · 2
老周物理老师 · 长文深读

愿意把一篇长文从头读到尾,遇到讲不通的地方会停下来深究、较真到底。

emulator-5558
persona · 3
小迪三秒划走

刷得飞快、自动驾驶;大多数内容三秒内划走,极少停留,几乎不开口。

emulator-5560
persona · 4
Vincent产品经理 · 探索狂点

把每个角落都点一遍、追问到底;越投入越挑剔,是最深度也最难取悦的那个。

emulator-5562
persona · 5
阿喵视觉动物 · 只看图

被画面驱动,封面不够好看就不点;图强则停,纯文字界面对她是「能忍但没惊喜」。

emulator-5564
persona · 6
陈叔等活儿司机 · 标题党碎片

碎片时间刷手机,等活儿的间隙看两眼;被标题勾住才点,看一半就走。

emulator-5566
persona · 7
林溪博士生 · 审稿找茬

带着审稿的眼睛刷内容,专挑事实错误、逻辑漏洞与含糊措辞,找到茬就要说。

emulator-5568
群体规模
8差异化 persona
会话上限
18轮 / closure.max_turns
真实端
mobileheadless 模拟器 + scrcpy 保活
并发
×8emulator-5554 … 5568

脚手架 launch / bootstrap_8users

引用式 API:先把 persona / scenario 建成资产拿 id,再逐台发 /runs/usaruntime={kind, app, serial})。每跑一次会 PATCH 同步最新 persona 文本;对 adb 未就绪的 serial 主动跳过,避免在 step0 硬失败。

保活 headless + scrcpy framebuffer

headless 模拟器跑批量,scrcpy 保活 framebuffer——没有前台镜像时屏幕内容会停更。8 台模拟器在线、装好大圆镜(com.grandmirror.gamma),RUN_MAX_CONCURRENT≥8 才有真 8 并发。

§ 08 — 群体执行

一个驱动器,
统一所有群体执行

WorldRunner 是 backend 编排层全部群体执行的唯一驱动器。原先四条手搓的 execute(predict / abtest / freeform / novel)已收敛成一条 strategies.execute_world, 差异全部下沉到 World 声明 + agent 装配。

这印证了群体层契约的泛化性:「独立采样」不是另一套机制,而是 IsolatedEnvSpec (零互投,agent 互不可见)+ lockstep 的退化形——大圆镜 8 人正是这种 isolated 介质下的独立 ensemble; in_memory / platform 介质则是耦合互动。freeform methodology 由开放声明驱动,幂等 seed。

读过的源码 · SOURCE TRACE  @ 2c16a02
engine/agent/agent.py  — UserSimulatorAgent · ReAct · operate 接缝 · cost/op_trace 折叠
engine/agent/inference.py  — decide(runtime=) · 四道防线 · tool_call 解包  198840a
engine/agent/prompt.py  — operate 工具按端条件化 · 反偏差 · 重锚(AgentMode 已删)
engine/runtime/exec.py · operate.py  — 执行层迁入 engine · ExecutionAgent / DirectShellExecutor  c0ac460 · fe160d6
engine/tests/launch_8users.py  — 大圆镜 8 人脚手架
engine/CLAUDE.md  — 设计宪法 §1–§7(命题 · 规划/执行两层 · 反偏差 · 群体层)