← 返回全部章节
01

Agent Runtime:把模型决策变成可靠执行

状态机、事务边界、终止条件、Planning,以及必须由 Runtime 守住的不变量。

6 分钟 01-agent-runtime.md

问题地图#

JD 信号 背后的工程能力 可以如何验证
Coding Agent 核心系统 能从零设计 Agent Runtime,不依赖框架黑盒 系统设计、伪代码、故障分析
执行循环、任务拆解、错误恢复 状态机、终止条件、重试语义、持久化恢复 “设计一个能跑一小时的 Agent”
文件、Shell、搜索、测试、Git 开发者工具基本功与安全边界 工具 API 设计、并发/取消、沙箱
仓库级上下文工程 检索、选择、压缩、记忆、token 预算 “百万行仓库如何找对上下文”
真实用户 trace、评测集 数据驱动调试,不靠 demo 和体感 给失败轨迹定位根因、设计 eval
模型协作与能力边界 能区分模型问题与 Runtime 问题 A/B、消融、错误归因
CLI / IDE / 远程执行 系统工程、交互体验、协议兼容 架构设计、端到端延迟
产品 sense 从用户任务而非技术炫技定义成功 竞品分析、需求优先级

一句话系统画像#

把概率性的模型决策,封装进确定性的软件约束与反馈闭环,使 Agent 在真实代码仓库中长期、可靠、安全地完成任务。

问题之间的关系#

主题 在系统中的关注度
Agent Loop 与 Runtime 核心状态与控制流
工具调用、Shell、编辑、Git、沙箱 副作用与安全边界
上下文工程与记忆 决策输入的质量
长任务、恢复、并发、Subagent 生产可靠性
Evaluation、Trace、Observability 迭代与归因闭环
产品与竞品 真实用户价值

本文跳过 Python 语法、装饰器、GIL 等语言基础,但会保留 asyncio 取消传播、子进程管理、结构化并发、流式 I/O 等与 Agent Runtime 直接相关的工程问题。


Agent Runtime:从聊天到闭环执行#

什么是 Coding Agent#

普通代码助手完成的是 输入 → 生成;Coding Agent 完成的是:

目标 → 观察环境 → 决策 → 调用工具 → 环境发生变化
    → 再观察 → 修正计划 → 验证 → 结束或交还用户

它至少包含四个要素:

  • 模型: 做语义理解、推理和动作选择;
  • 工具: 读取与改变外部世界;
  • 状态: 保存目标、消息、计划、任务、权限和产物;
  • 控制器: 决定何时调用模型、执行工具、压缩、重试、暂停和结束。

Agent 和 Workflow 有什么区别?#

Workflow 的路径主要由代码预定义,模型填充局部节点;Agent 的下一步路径主要由模型根据环境反馈动态决定。它们不是二选一。生产系统通常是“确定性外壳包住 Agent”:权限、预算、重试、状态转换由代码控制,任务内的搜索、编辑和验证策略由模型选择。能用确定性流程解决的部分不应无谓 Agent 化。

为什么 Coding Agent 比一般 Agent 难?#

  • 代码仓库大而稀疏,正确上下文只占极小部分;
  • 工具具有真实副作用,错误命令可能破坏工作区或泄露数据;
  • 任务成功往往延迟到测试、构建甚至用户验收后才知道;
  • 软件任务是长尾分布,语言、构建系统、仓库规范差异大;
  • 长链路中早期小错误会累积,模型容易偏离目标;
  • “测试通过”也可能是删测试、过拟合可见测试或改变无关行为;
  • 用户会中途追加要求、打断、修改文件,环境并非静态。

一个生产级 Agent Runtime#

flowchart TB U[用户 / IDE / CLI] --> S[Session & Interaction API] S --> C[Agent Controller] C --> B[Context Builder] B --> M[Model Gateway] M --> D{模型输出} D -->|文本/结束| V[Verifier & Completion Policy] D -->|工具调用| P[Policy / Approval] P --> X[Tool Executor] X --> E[Workspace / Shell / Git / Web / MCP] E --> O[Observation Normalizer] O --> T[Event Log / Trace] T --> B C --> K[Task / Plan / Memory Store] V -->|未满足| C V -->|完成| S C --> Q[Subagent / Background Task Scheduler] Q --> T

核心原则:

  • 模型不是状态机。 模型可以建议动作,Runtime 必须验证动作是否允许。
  • 事件日志是真相源。 UI 展示、恢复、评测和调试应尽量由同一事件流投影。
  • 可取消是一级能力。 模型流、工具、子进程、Subagent 都要有明确取消语义。
  • 结果必须闭环。 每个 tool call 都应有对应 result,包括失败、拒绝和中断。
  • 终止不是一句“完成了”。 应由完成策略结合测试、diff、任务约束和预算判断。

Agent Loop 状态机#

stateDiagram-v2 [*] --> Ready Ready --> BuildingContext BuildingContext --> CallingModel CallingModel --> ExecutingTools: tool_calls CallingModel --> Verifying: final_answer CallingModel --> Retrying: transient_error ExecutingTools --> CallingModel: observations ExecutingTools --> AwaitingApproval: risky_action AwaitingApproval --> ExecutingTools: approved AwaitingApproval --> CallingModel: rejected_result Verifying --> CallingModel: incomplete Verifying --> Completed: acceptance_met Retrying --> CallingModel: retry_budget_ok Retrying --> Failed: exhausted Ready --> Cancelled: user_cancel CallingModel --> Cancelled: user_cancel ExecutingTools --> Cancelled: user_cancel CallingModel --> Compacting: context_pressure Compacting --> CallingModel: compacted_context

下面这段伪代码刻意把状态与边界显式化:

async def run_turn(session, user_input):
    await session.append_event(UserMessage(user_input))
    budget = Budget(max_steps=80, max_cost=5.0, deadline=now() + 30 * MINUTE)

    while not budget.exhausted():
        snapshot = await session.snapshot()
        context = await context_builder.build(snapshot, budget.remaining())

        try:
            response = await model.generate(
                context=context,
                tools=tool_registry.schemas(snapshot.permission_mode),
                cancel_scope=session.cancel_scope,
            )
        except ContextOverflow:
            await compact(session)
            continue
        except TransientProviderError as error:
            await retry_with_backoff(error, budget)
            continue

        await session.append_event(ModelResponse(response))

        if response.tool_calls:
            validated = policy.validate(response.tool_calls, snapshot)
            results = await execute_with_limits(validated, session.cancel_scope)
            # 成功、失败、拒绝、中断都写入结果,保持调用/结果配对
            await session.append_events(results)
            budget.charge(results)
            continue

        verdict = await completion_policy.verify(response, snapshot)
        if verdict.accepted:
            await session.append_event(TurnCompleted(verdict))
            return response.final_text

        await session.append_event(VerifierFeedback(verdict.feedback))

    return await hand_back_to_user(reason=budget.exhaustion_reason)

循环何时结束?#

不能只依赖模型说“done”。终止条件分四类:

  • 任务成功: 验收条件满足,必要测试通过,diff 符合范围;
  • 需要用户: 需求歧义、高风险操作、凭据或外部信息缺失;
  • 资源耗尽: step、token、成本、时间、重试预算到达上限;
  • 不可恢复失败: 环境损坏、权限拒绝、确定性错误无法绕过。

我的取舍是“宁可显式 handoff,也不能无限循环”。循环检测可使用:

  • 连续相同工具与近似参数;
  • workspace hash / git diff 长时间不变;
  • 同一错误签名重复;
  • 计划节点没有进展;
  • 观察文本相似度过高;
  • 单位成果成本持续恶化。

如何避免模型不停重试同一个错误?#

我会把错误结构化为 kind / retryable / blame / signature / suggested_action。网络超时可指数退避并加 jitter;schema 错误应反馈具体字段让模型修正;权限拒绝应作为确定结果回到上下文;编译失败要保留关键诊断;相同错误签名超过阈值后禁止原动作,要求换策略或交还用户。重试预算按错误域分别计算,避免一个工具吃光整轮预算。

Planning 与执行#

计划的价值不是让回答看起来有条理,而是:

  • 外化长任务状态,降低目标遗忘;
  • 提供进度与可解释性;
  • 形成可检查的中间契约;
  • 便于失败恢复和多人/多 Agent 协作。

计划不应成为僵硬脚本。推荐结构:

goal: 修复并验证鉴权缓存竞态
acceptance:
  - 最小复现测试在修复前失败、修复后通过
  - 不改变公开 API
constraints:
  - 不升级无关依赖
steps:
  - id: reproduce
    status: completed
    evidence: tests/test_auth_cache.py::test_concurrent_refresh
  - id: locate
    status: in_progress
  - id: patch
    status: pending
  - id: verify
    status: pending
open_questions:
  - token refresh 是否允许重复但结果幂等?

每个任务都要先生成完整计划吗?#

不需要。计划成本应与任务不确定性匹配。单文件明确修改可直接执行;跨模块、有高副作用或超过若干步的任务先做轻量计划;架构变更先只读探索并让用户审阅。计划应滚动更新,环境证据推翻假设时允许重规划。

我会先守住六个 Runtime 不变量#

Agent Loop 很容易写出来,真正难的是让任意异常都不能破坏下面这些条件:

  1. 一个逻辑动作只有一个身份。 重试可以产生多个物理 attempt,但不能让外部系统把它们当成多个业务动作。
  2. 每个工具调用最终闭合。 成功、失败、拒绝、取消和结果未知都必须形成 terminal result。
  3. 历史事件只解释,不重演。 replay 用来重建状态,绝不能顺便再次执行 Shell、写文件或发布。
  4. 权限只能收缩,不能由模型提升。 模型可以请求能力,授权主体只能是策略或用户。
  5. 完成必须带证据。 “我已经修好”不是状态转换条件,测试、diff 和 acceptance 才是。
  6. 取消最终会收敛。 模型流、子进程、后台任务和 Subagent 都必须在可观测时间内结束或进入明确的 unknown 状态。

我倾向于把这些不变量写进 reducer 和 property-based test,而不是只放在 system prompt 里。Prompt 能影响模型选择,但不能承担一致性。

一次 Turn 更像事务,而不是一次函数调用#

假设 Agent 要修复一个鉴权缓存竞态。模型提出运行测试,测试进程成功退出,但 Runtime 在写入 ToolExecutionFinished 之前崩溃。恢复时,系统看到的最后一个事实仍然是 ToolExecutionStarted

这时有三个看似合理、实际完全不同的动作:

  • 直接重跑:对只读测试通常可以,但若命令包含数据迁移就可能重复副作用;
  • 直接假定成功:会把未验证结果写进上下文;
  • 标记结果未知并 reconcile:最保守,也最符合事实。

因此我会把工具生命周期拆成意图、attempt 和结果三层:

LogicalAction
  action_id        # 跨重试稳定
  intent_hash      # 规范化参数 + scope + workspace revision

ToolAttempt
  attempt_id       # 每次物理执行唯一
  action_id
  started_at
  executor_id
  deadline

ToolOutcome
  action_id
  status           # succeeded | failed | cancelled | rejected | unknown
  effect_fingerprint
  artifact_refs

action_id 解决逻辑幂等,attempt_id 保留真实执行次数,effect_fingerprint 用来恢复时核对外部世界。例如文件编辑的 fingerprint 可以包含目标路径、base hash 和结果 hash;远端发布则应优先使用对方支持的 idempotency key 或查询发布记录。

事件落盘和副作用无法原子提交怎么办?#

大多数工具无法和本地 event store 做分布式事务。我不会假装存在 exactly-once,而是按工具能力选择:

工具类型 恢复策略
文件读取、搜索 安全重放
带 base hash 的文件编辑 检查结果 hash;已应用则补记成功,冲突则返回 stale
可查询状态的远端任务 用 action ID 查询并 reconcile
支持幂等键的 API 以逻辑 action ID 重试
无查询、无幂等的外部动作 标记 unknown_outcome,禁止自动重放

这条边界决定 Runtime 是否可信。只讨论“失败后重试三次”而不讨论未知结果,通常还停留在普通 API 客户端的思路。

一个跨文件修改的完整控制流#

以“给鉴权客户端增加请求去重,同时不改变公开 API”为例,我会让一次任务经历下面的状态:

1. 接收目标,抽取 acceptance 与 forbidden changes
2. 读取仓库规则、Git 基线和当前 dirty diff
3. 搜索 token refresh 定义、调用方与并发测试
4. 形成最小假设:竞态发生在 cache miss 到写回之间
5. 先写可稳定复现的并发测试
6. 测试失败,记录错误签名与执行环境
7. 基于 base hash 应用局部 patch
8. 运行目标测试、相关模块测试和静态检查
9. 比较最终 diff 与任务范围,确认没有改测试语义
10. verifier 检查 acceptance,生成带证据的完成事件

关键不是固定这十步,而是每一步都产生下一步可以复用的证据。若第 6 步测试没有稳定失败,就不应假装已经复现;若第 8 步环境发生变化,成功证据必须绑定新的 revision。这样计划才是运行状态,而不是展示给用户看的装饰。