法不净空,觉无性也。

第22章 有用户 vs. 无用户——两种 agent 模式

2026.07.29

"有用户在场"和"无用户在场"不是同一个东西的开启/关闭状态,是两种完全不同的模式。


引子

很多 agent 系统把"有用户"和"无用户"当作同一个运行实例的两个状态:有用户时弹框确认,无用户时自动审批。好像是同一个引擎,只是改一个 flag。

这是第一个危险的想法。

第二个危险的想法是:无人值守就是"把所有权限预授权了然后让 agent 自己跑"。听起来简单,但用户能做的事远不止批准权限——用户可以回答问题、澄清需求、提供补充信息,甚至说"换个思路,从另一边试试"。这些能力在无用户模式下全部消失了。

无用户模式不是"把权限放开就行了"。它是一种完全不同的交互模型,需要从架构上重新设计。

交互链路的本质差异

有用户模式下,agent 和用户之间是一条双向通道:

用户 → agent:指令、澄清、否定
agent → 用户:进度、问题、确认请求

这条通道最重要的特性是:agent 可以追问。 它遇到不确定的事,可以问。它可以确认用户的意图,可以请求额外的上下文。用户也可以主动打断——"不对,换个方向做"——此时 agent 调整计划。

无用户模式下,这条双向通道变成了单向:

agent → 系统:工具调用
系统 → agent:工具结果

没有追问的余地。agent 遇到不确定的事,唯一的办法是在现有的信息范围内做最佳决策。它不能用"请确认一下"这种操作——因为没有人可问。

这带来一个设计推论:无用户模式的 agent 必须在启动前,把所有需要决策的点预判完。

比如,一个代码重构任务——有用户时,agent 可以中途问"这个模块要不要保留旧接口做兼容"。无用户时,agent 必须一开始就知道"保留旧接口做兼容"这个策略,或者写成一条规则写在指令里。它不能边做边问。

权限模型的切换

权限模型是最明显的差异——但也是最容易被误认为"唯一差异"的地方。

有用户时,CodeCoder 的权限模型走 Ask 模式:agent 要执行 run_command:git,弹窗问用户;用户说 yes 或 no,系统记录这次选择的权限范围(Once / ThisSession / ThisProject)。

无用户时,没有窗口可弹。所以模型切换为:

  • 预授权codecoder.json 中声明了哪些工具的哪些 key 可以直接执行。比如 run_command:git 走预授权,不用问任何人
  • 自动拒绝:任何未在预授权列表中的工具调用被直接拒绝,并返回一个 ToolFinished{is_error: true} 事件。注意——它不阻塞 agent 的执行。Agent 收到拒绝后,应该设计绕过方案:比如换一个不需要该工具的实现方式
  • 熔断:连续被拒绝或卡在某一步太多次,主动终止

这种"拒绝但不阻塞"的设计很关键。如果拒绝导致整个 agent 卡住,那无用户模式就只适合那些"所有需要的东西都能提前预见到"的极简单场景。实际上,优秀的无用户 agent 会在被拒绝后自动换一条路径:不能用 curl 那就用 read_file 读本地缓存的版本;不能直接写文件那就输出到 stdout 让调度器捕获。

CodeCoder 在无用户模式下还有一个熔断机制:k 次连续失败后主动终止。 这防止了 agent 在同一个死胡同里反复兜圈子。退出码报告是"StuckNeedsFix"(2),上层调度器可以根据这个码决定是重试、还是通知人工介入。

恢复与退出

有用户模式下,退出很简单:用户关终端。Session 自动保存,下次 /resume 继续。

无用户模式下,退出必须有明确的契约。因为调用者不是人,是一个调度脚本或 CI 系统,它们需要从退出码判断下一步做什么。

CodeCoder 的无用户退出码契约是:

  • 0 — 正常完成。所有里程碑 done,任务完成了
  • 2 — StuckNeedsFix。有里程碑卡在 needs_fix,重试预算耗尽
  • 3-5 — 异常。图结构问题、初始化失败等

崩溃恢复靠两个机制:

  1. Stamp 文件:agent 启动时写一个时间戳文件,正常退出时删除。下次启动时如果发现 stamp 存在,说明上次是异常终止
  2. Supervisor state:持久化保存每个 Capability 的 crash_count、gave_up 状态。重启后跳过崩溃超限的服务,不再尝试 spawn

重要的是:退出码只告诉调用者"完成状态",不告诉"原因"。 原因在 BgObserver 写下的 .ccd.bg.ndjson 文件里——每行一个 JSON 事件,记录了每一步的工具调用、结果、错误。调度器可以 tail -f 实时观察,离线读取完整记录。

有用户 + 无用户的切换不是开关,是换挡

把这两种模式当作同一个东西的两个状态,会导致设计上的盲区:

  • 无用户模式不能使用 ask_user 工具——但 agent 可能不知道"当前没有用户"。它可能会尝试弹窗然后卡住。解决方案是:启动时通过 CODECODER_BG_TASKCODECODER_BG_WORKGRAPH 环境变量明确告知 agent 进入无用户模式,并在 system prompt 中声明"你当前没有用户通道,不能追问"
  • 有用户模式下累积的 session allowlist 在无用户模式下无效——因为 allowlist 存在内存里,重启就没了。无用户模式只认 codecoder.json 的项目级 allowlist
  • 测试策略不同:有用户时测试需要模拟用户确认;无用户时测试要预配置授权列表并验证自动拒绝行为

把这些差异全部考虑进去后,两种模式的架构图是这样的:

有用户模式:TUI/Socket → agent → 工具(Ask 弹窗)→ 工具结果 → 用户
无用户模式:环境变量 → agent → 工具(预授权/自动拒绝)→ 工具结果 → ndjson → 退出码

两条路径共用一个 agent 内核,但交互层、权限层、退出层完全不同。它们不是同一个代码路径里的 if-else 分支——它们是两个不同的入口。

代价与权衡

有/无用户双模式的支持不是无代价的工程选择。

首先,两条入口路径意味着两倍的维护复杂度。 权限系统需要同时工作在 "Ask → 弹窗 → 用户选择" 和 "预授权 → 自动拒绝" 两条路径上。如果权限模块内部重构,两边的行为都需要验证。CodeCoder 用同一个权限内核 + 不同的前端策略(内存中的 SessionAllowlist vs. 文件中的 ProjectAllowlist)来缓解这个问题,但测试矩阵扩大一倍是事实。

其次,无用户模式的预授权列表存在过度授权风险。 你在 codecoder.json 中写下 run_command:git 允许——但 agent 可以用 git 推送到远程、删除分支、修改提交信息。预授权的粗粒度意味着:只要你给了某个 key,agent 就能用它做这个 key 允许范围内的任何事情,而 CI 场景下没有人在循环里叫停。这是无用户模式固有的信任敞口——你必须在便利性和安全性之间做取舍。

第三,两种模式之间的切换成本被低估了。 开发阶段用交互式模式写好和调试好 workgraph,切换到无用户模式部署到 CI 环境时,你可能会遇到"交互式模式下正常工作,无用户模式下在某一步卡住了"的情况,因为交互模式下用户可能在不知不觉中批准了一些东西,而无用户模式下同样的操作因为缺少预授权被拒绝。逐条对比交互式 session 和无用户配置是一个繁琐但必要的部署步骤。

收尾部分的设计检查清单就是为了覆盖这些盲区——但即使如此,从交互式到无用户的第一轮切换几乎总会发现遗漏的预授权权限。

收尾:设计检查清单

如果你设计的 agent 要支持无用户模式,用这份清单检查:

  1. 谁批准操作? → 预授权列表够全吗?需要动态授权怎么办?
  2. 错误往哪报? → 没有终端窗口,错误走 stderr 还是文件?
  3. 怎么知道它卡住了? → 有心跳检测吗?超时多少合适?
  4. 它能不能追问? → 如果不能,预先给了足够多的上下文吗?
  5. 重试策略是什么? → 失败时重试还是换路径?重试几次?
  6. 退出码谁消费? → 上层调度器能理解这些码吗?有没有降级处理?

这六个问题中任何一个答不上来,无用户模式就不应该上线。

下一篇,我们聚焦一个具体的问题——当 agent 说"做完了",你怎么知道它真的做完了?