{"schema_version":1,"id":"article:agent-harness-run-api","kind":"article","slug":"agent-harness-run-api","title":"Agent Loop 不等于上线：Run API 与「两本账、八张表」","author":"晨旭","url":"https://chenxu.xin/writing/agent-harness-run-api","date":"2026-07-27","updated":"2026-07-27","description":"本地跑通 Agent Loop 后用 FastAPI 包一层就能上线？拆解 Harness 工程的三个目标与七个模块，重点讲 Run API 的资源建模和数据库的两本账、八张表。","position":null,"group":null,"series":"动手落地AI","tags":["agent","harness","hands-on"],"publication_status":"public","maturity":"growing","body_sha256":"86106a3c3a9e5a2db8f680976d7496a911024ea30a79acaca29a92017ac956d3","paragraph_rule":"src/lib/paragraphs.mjs@1","paragraphs":[{"id":"p1","kind":"paragraph","text":"在前面的学习中，我们已经拆过 LLM、工具调用，也理解了 Agent Loop：","raw":"在前面的学习中，我们已经拆过 LLM、工具调用，也理解了 Agent Loop："},{"id":"p2","kind":"quote","text":"用户提出目标 → 模型思考 → 调用工具 → 拿到结果 → 再思考，直到完成任务。","raw":"> 用户提出目标 → 模型思考 → 调用工具 → 拿到结果 → 再思考，直到完成任务。"},{"id":"p3","kind":"paragraph","text":"当这个循环在本地顺利跑通时，很容易产生一种错觉：Agent 已经做好了，接下来只要用 FastAPI 包一层，就可以上线了。","raw":"当这个循环在本地顺利跑通时，很容易产生一种错觉：Agent 已经做好了，接下来只要用 FastAPI 包一层，就可以上线了。"},{"id":"p4","kind":"paragraph","text":"在我深入学习 Harness 工程以及接触线上项目之前，我也是这么认为的。但实际上远非如此简单。","raw":"在我深入学习 Harness 工程以及接触线上项目之前，我也是这么认为的。但实际上远非如此简单。"},{"id":"p5","kind":"paragraph","text":"想象一个场景：如果你的 Agent 是一个负责处理客服工单的 AI 员工，它的任务链可能长达几分钟甚至几个小时。在这个过程中：","raw":"想象一个场景：如果你的 Agent 是一个负责处理客服工单的 AI 员工，它的任务链可能长达几分钟甚至几个小时。在这个过程中："},{"id":"p6","kind":"list","text":"- 如果 HTTP 请求超时了怎么办？\n- 如果 worker 崩溃了，重启后它是从头再跑一遍，还是能从断点恢复？\n- 如果用户网络卡顿，连续点了三次退款按钮，你的系统是不是会给用户退三次钱呢？","raw":"- 如果 HTTP 请求超时了怎么办？\n- 如果 worker 崩溃了，重启后它是从头再跑一遍，还是能从断点恢复？\n- 如果用户网络卡顿，连续点了三次退款按钮，你的系统是不是会给用户退三次钱呢？"},{"id":"p7","kind":"paragraph","text":"面对这些关乎生产安全的拷问，纯粹的 LLM 和 Agent Loop 是无法回答的。这时我们就需要一套「马具」，也就是我们熟知的 Harness Engineering。","raw":"面对这些关乎生产安全的拷问，纯粹的 LLM 和 Agent Loop 是无法回答的。这时我们就需要一套「马具」，也就是我们熟知的 **Harness Engineering**。"},{"id":"p8","kind":"paragraph","text":"除去 LLM 本身和核心的 Agent Loop（prompt、模型调用策略等），剩下的所有为了让 Agent 在真实业务环境中稳定运行的后端服务工程，就是这篇文章以及之后几篇文章要深度拆解的内容。本文分为三个部分：","raw":"除去 LLM 本身和核心的 Agent Loop（prompt、模型调用策略等），剩下的所有为了让 Agent 在真实业务环境中稳定运行的后端服务工程，就是这篇文章以及之后几篇文章要深度拆解的内容。本文分为三个部分："},{"id":"p9","kind":"list","text":"- Agent Loop 之外的 Harness Engineering\n- Run API 设计：请求归请求，执行归执行\n- 数据库设计：两本账与八张表","raw":"- Agent Loop 之外的 Harness Engineering\n- Run API 设计：请求归请求，执行归执行\n- 数据库设计：两本账与八张表"},{"id":"p10","kind":"paragraph","text":"（文章中所有图片归深圳途明智启科技有限公司版权所有。）","raw":"（文章中所有图片归深圳途明智启科技有限公司版权所有。）"},{"id":"p11","kind":"heading","text":"Harness 工程：三个目标与七个核心模块","raw":"## Harness 工程：三个目标与七个核心模块"},{"id":"p12","kind":"paragraph","text":"做一个拥有真实用户的 AI 应用，最基本的要求就是服务要稳定。但这在工程上是很模糊的表述，具体要怎么实现呢？","raw":"做一个拥有真实用户的 AI 应用，最基本的要求就是服务要稳定。但这在工程上是很模糊的表述，具体要怎么实现呢？"},{"id":"p13","kind":"paragraph","text":"在 Harness 工程中，需要将其转化为可量化的 SLO（服务级目标），例如延迟必须小于多少、成功率大于多少、队列滞后小于多少秒。总体可以把整个 Harness 工程的设计围绕着三个核心目标和七个核心模块展开。","raw":"在 Harness 工程中，需要将其转化为可量化的 SLO（服务级目标），例如延迟必须小于多少、成功率大于多少、队列滞后小于多少秒。总体可以把整个 Harness 工程的设计围绕着三个核心目标和七个核心模块展开。"},{"id":"p14","kind":"heading","text":"1、三个核心目标","raw":"### 1、三个核心目标"},{"id":"p15","kind":"paragraph","text":"跑得稳（稳定与可控）：管理一次不确定性、长生命周期、有副作用、可恢复的执行过程。任何一个节点崩溃，系统都能自我治愈并从断点恢复，同时绝对保证写操作（如退款）的安全，实现高并发与高可用。","raw":"**跑得稳（稳定与可控）**：管理一次不确定性、长生命周期、有副作用、可恢复的执行过程。任何一个节点崩溃，系统都能自我治愈并从断点恢复，同时绝对保证写操作（如退款）的安全，实现高并发与高可用。"},{"id":"p16","kind":"paragraph","text":"可监控（可追溯与审计）：AI Native 时代，API 调用皆是成本。系统必须精确监控 Token 消耗、工具调用耗时，并保证每一步执行都落库可查，提供清晰的事件流（SSE）追踪和事后审计能力。","raw":"**可监控（可追溯与审计）**：AI Native 时代，API 调用皆是成本。系统必须精确监控 Token 消耗、工具调用耗时，并保证每一步执行都落库可查，提供清晰的事件流（SSE）追踪和事后审计能力。"},{"id":"p17","kind":"paragraph","text":"可进化（Self-Evolve）：系统运行时留下的完整 Trace 数据，是数据飞轮的重要来源。基于这些真实的运行轨迹，通过数据清洗、评估和人工标注，形成闭环的微调和强化学习，驱动模型与系统的自我迭代。","raw":"**可进化（Self-Evolve）**：系统运行时留下的完整 Trace 数据，是数据飞轮的重要来源。基于这些真实的运行轨迹，通过数据清洗、评估和人工标注，形成闭环的微调和强化学习，驱动模型与系统的自我迭代。"},{"id":"p18","kind":"heading","text":"2、七个核心模块","raw":"### 2、七个核心模块"},{"id":"p19","kind":"paragraph","text":"为了支撑上述目标，可以把这一整套后端服务拆解为七大模块：","raw":"为了支撑上述目标，可以把这一整套后端服务拆解为七大模块："},{"id":"p20","kind":"list","text":"1. Run API：请求入口与资源建模\n2. 状态与事件储存（Database）：两本账与八张表，系统的唯一事实源\n3. 队列与 Worker：长任务的异步派发与并发控制\n4. 状态与状态机转移：严格约束任务的生命周期演进\n5. Agent Loop 运行时：真实的模型与工具交互逻辑\n6. 工具运行时与安全：带副作用工具的鉴权、重试与防重控制\n7. 实时进度面板（SSE 事件流）：给前端用户的实时反馈","raw":"1. **Run API**：请求入口与资源建模\n2. **状态与事件储存（Database）**：两本账与八张表，系统的唯一事实源\n3. **队列与 Worker**：长任务的异步派发与并发控制\n4. **状态与状态机转移**：严格约束任务的生命周期演进\n5. **Agent Loop 运行时**：真实的模型与工具交互逻辑\n6. **工具运行时与安全**：带副作用工具的鉴权、重试与防重控制\n7. **实时进度面板（SSE 事件流）**：给前端用户的实时反馈"},{"id":"p21","kind":"paragraph","text":"这七个模块看起来很多，但把图从左向右看就会发现：一切都从用户发起请求开始。","raw":"这七个模块看起来很多，但把图从左向右看就会发现：一切都从用户发起请求开始。"},{"id":"p22","kind":"paragraph","text":"那么，用户点击「让 AI 处理」后，后端究竟应该创建什么？","raw":"那么，用户点击「让 AI 处理」后，后端究竟应该创建什么？"},{"id":"p23","kind":"paragraph","text":"答案不是一条必须保持连接的 HTTP 请求，而是一项拥有独立身份、能够长期存在的工作：Run。","raw":"答案不是一条必须保持连接的 HTTP 请求，而是一项拥有独立身份、能够长期存在的工作：**Run**。"},{"id":"p24","kind":"paragraph","text":"那一次点击是如何进入整套 Harness 系统？它为什么不能只是一条 HTTP 请求？当 HTTP 已经结束，Run 又依靠什么继续活着？","raw":"那一次点击是如何进入整套 Harness 系统？它为什么不能只是一条 HTTP 请求？当 HTTP 已经结束，Run 又依靠什么继续活着？"},{"id":"p25","kind":"paragraph","text":"顺着这三个问题，就来到了本文的重点。","raw":"顺着这三个问题，就来到了本文的重点。"},{"id":"p26","kind":"heading","text":"Run API 设计：请求归请求，执行归执行","raw":"## Run API 设计：请求归请求，执行归执行"},{"id":"p27","kind":"paragraph","text":"在做 demo 阶段，我们通常让前端发请求，后端阻塞等待，直到 Agent 跑完整个流程再返回结果。但是这种做法在真实业务中是完全不可行的。","raw":"在做 demo 阶段，我们通常让前端发请求，后端阻塞等待，直到 Agent 跑完整个流程再返回结果。但是这种做法在真实业务中是完全不可行的。"},{"id":"p28","kind":"paragraph","text":"一个真实 Agent 任务（例如处理工单）涉及多次模型调用、工具查询、甚至等待人工确认，耗时可能长达数分钟。同步等待会导致网关超时，且一旦网络断开，执行进度就立刻消失了；更甚者，前端报错重试，后端其实已经完成了具有副作用的操作（如退款），最终造成了生产事故：双重退款。","raw":"一个真实 Agent 任务（例如处理工单）涉及多次模型调用、工具查询、甚至等待人工确认，耗时可能长达数分钟。同步等待会导致网关超时，且一旦网络断开，执行进度就立刻消失了；更甚者，前端报错重试，后端其实已经完成了具有副作用的操作（如退款），最终造成了生产事故：双重退款。"},{"id":"p29","kind":"paragraph","text":"因此，Run API 的设计哲学是：请求归请求，执行归执行。我们将一次大模型的执行，建模为一个长期存在的系统资源——Run（用 run_id 来追踪）。","raw":"因此，Run API 的设计哲学是：**请求归请求，执行归执行**。我们将一次大模型的执行，建模为一个长期存在的系统资源——Run（用 `run_id` 来追踪）。"},{"id":"p30","kind":"heading","text":"1、餐厅点餐模型：API 与 Worker 的异步协作","raw":"### 1、餐厅点餐模型：API 与 Worker 的异步协作"},{"id":"p31","kind":"paragraph","text":"这里可以用「餐厅点餐」来类比这个解耦的过程：","raw":"这里可以用「餐厅点餐」来类比这个解耦的过程："},{"id":"p32","kind":"paragraph","text":"前台收单（Run API）：API 的作用不是去把任务跑完，而是建立契约。当前端发起任务，API 层进行身份校验、参数校验，然后在数据库中登记一次「排队中」的工单事务，并立刻（毫秒级）给前端返回一个取餐号（run_id）。","raw":"**前台收单（Run API）**：API 的作用不是去把任务跑完，而是建立契约。当前端发起任务，API 层进行身份校验、参数校验，然后在数据库中登记一次「排队中」的工单事务，并立刻（毫秒级）给前端返回一个取餐号（`run_id`）。"},{"id":"p33","kind":"paragraph","text":"后厨排队制作（Queue & Worker）：后台的 Worker 就像厨师，他们从队列里接单（拿取 run_id 对应的任务），一步步执行大模型的思考与工具调用。","raw":"**后厨排队制作（Queue & Worker）**：后台的 Worker 就像厨师，他们从队列里接单（拿取 `run_id` 对应的任务），一步步执行大模型的思考与工具调用。"},{"id":"p34","kind":"paragraph","text":"随时看进度（SSE 订阅）：前端拿着取餐号，可以随时查看到底是「已下锅」还是「装盘中」，甚至可以主动退单（Cancel）。","raw":"**随时看进度（SSE 订阅）**：前端拿着取餐号，可以随时查看到底是「已下锅」还是「装盘中」，甚至可以主动退单（Cancel）。"},{"id":"p35","kind":"paragraph","text":"Run API 的职责不是把任务当场就做完，而是创建一份可被长期管理的工作，并把控制权交还给前端。","raw":"Run API 的职责不是把任务当场就做完，而是创建一份可被长期管理的工作，并把控制权交还给前端。"},{"id":"p36","kind":"heading","text":"2、支撑解耦的六个核心 API 入口","raw":"### 2、支撑解耦的六个核心 API 入口"},{"id":"p37","kind":"paragraph","text":"围绕 Run 这个资源，后端需要提供 6 个标准的接口：","raw":"围绕 Run 这个资源，后端需要提供 6 个标准的接口："},{"id":"p38","kind":"list","text":"1. 创建 Run（POST /runs）：毫秒级响应，创建执行任务并立即返回 run_id，状态标记为 queued。\n2. 查询当前状态（GET /runs/{id}）：用户刷新页面后，通过 run_id 获取任务当前是 running 还是 succeeded。\n3. 订阅实时事件（GET /runs/{id}/events）：建立 SSE 长连接，后端向前端推送每一步的微观进度（如 model.started、tool.completed），实现良好的可观察性。\n4. 取消请求（POST /runs/{id}/cancel）：允许用户中止卡住的或错误的执行。需要注意的是，如果是运行中的取消，API 层只做「标记请求」，真正的停止由 Worker 在安全点完成，避免在写入数据库中途强行阻断。\n5. 恢复执行（POST /runs/{id}/resume）：针对需要人工干预（比如 waiting for user）的场景。例如大额退款挂起，等待主管审核通过后，调用此接口让 Worker 从 Checkpoint 断点续跑。\n6. 完整轨迹追踪（GET /runs/{id}/trace）：事后复盘与审计，拉取整个任务周期的模型交互、工具调用明细。","raw":"1. **创建 Run（POST /runs）**：毫秒级响应，创建执行任务并立即返回 `run_id`，状态标记为 queued。\n2. **查询当前状态（GET /runs/{id}）**：用户刷新页面后，通过 `run_id` 获取任务当前是 running 还是 succeeded。\n3. **订阅实时事件（GET /runs/{id}/events）**：建立 SSE 长连接，后端向前端推送每一步的微观进度（如 `model.started`、`tool.completed`），实现良好的可观察性。\n4. **取消请求（POST /runs/{id}/cancel）**：允许用户中止卡住的或错误的执行。需要注意的是，如果是运行中的取消，API 层只做「标记请求」，真正的停止由 Worker 在安全点完成，避免在写入数据库中途强行阻断。\n5. **恢复执行（POST /runs/{id}/resume）**：针对需要人工干预（比如 waiting for user）的场景。例如大额退款挂起，等待主管审核通过后，调用此接口让 Worker 从 Checkpoint 断点续跑。\n6. **完整轨迹追踪（GET /runs/{id}/trace）**：事后复盘与审计，拉取整个任务周期的模型交互、工具调用明细。"},{"id":"p39","kind":"paragraph","text":"这六个接口合在一起，建立了一份长期任务契约：POST 赋予它身份，GET、events 和 trace 负责观察，cancel 与 resume 负责控制。","raw":"这六个接口合在一起，建立了一份长期任务契约：POST 赋予它身份，GET、events 和 trace 负责观察，cancel 与 resume 负责控制。"},{"id":"p40","kind":"heading","text":"3、生产级 API 的硬性要求：幂等与多租户隔离","raw":"### 3、生产级 API 的硬性要求：幂等与多租户隔离"},{"id":"p41","kind":"paragraph","text":"在实现这六个接口时，有两个重要的约束。","raw":"在实现这六个接口时，有两个重要的约束。"},{"id":"p42","kind":"paragraph","text":"其一是多租户隔离：在所有的读写操作中，必须绑定 org_id。查询不仅要在业务逻辑里判断，还要下沉到 SQL 语句中（WHERE id=:run_id AND org_id=:org_id），防止跨用户的数据越权。","raw":"**其一是多租户隔离**：在所有的读写操作中，必须绑定 `org_id`。查询不仅要在业务逻辑里判断，还要下沉到 SQL 语句中（`WHERE id=:run_id AND org_id=:org_id`），防止跨用户的数据越权。"},{"id":"p43","kind":"paragraph","text":"其二是幂等设计：这个是解决「网络抖动导致重复退款」的唯一解法。","raw":"**其二是幂等设计**：这个是解决「网络抖动导致重复退款」的唯一解法。"},{"id":"p44","kind":"paragraph","text":"每次创建 Run 时，前端必须生成并携带一个基于业务实体的唯一键（如 ticket:T-9527:handle:v1）。在数据库层面，对 org_id 和 idempotency_key 建立唯一联合约束：","raw":"每次创建 Run 时，前端必须生成并携带一个基于业务实体的唯一键（如 `ticket:T-9527:handle:v1`）。在数据库层面，对 `org_id` 和 `idempotency_key` 建立唯一联合约束："},{"id":"p45","kind":"list","text":"- 当相同的幂等键、相同的 Input 再次请求时，后端不报错，也不新建任务，而是直接返回第一次成功创建的 HTTP 200 及原本的 run_id\n- 如果相同的幂等键却传入了不同的 Input，后端应立刻拦截并返回 HTTP 409（IDEMPOTENCY_CONFLICT）","raw":"- 当相同的幂等键、相同的 Input 再次请求时，后端不报错，也不新建任务，而是直接返回第一次成功创建的 HTTP 200 及原本的 `run_id`\n- 如果相同的幂等键却传入了不同的 Input，后端应立刻拦截并返回 HTTP 409（IDEMPOTENCY_CONFLICT）"},{"id":"p46","kind":"paragraph","text":"重复的请求本身不是错误，是分布式系统的常态，但幂等机制是保障系统不发生不可逆副作用的底线。","raw":"**重复的请求本身不是错误，是分布式系统的常态，但幂等机制是保障系统不发生不可逆副作用的底线。**"},{"id":"p47","kind":"paragraph","text":"到这里，Run API 解决了两个问题：它让任务拥有稳定身份，也让前端获得了观察和控制任务的入口。但一个更根本的问题随之出现：","raw":"到这里，Run API 解决了两个问题：它让任务拥有稳定身份，也让前端获得了观察和控制任务的入口。但一个更根本的问题随之出现："},{"id":"p48","kind":"quote","text":"POST /runs 已经返回，HTTP 连接已经结束，run_id 如何能在几分钟后被查询、在 Worker 崩溃后被恢复呢？","raw":"> `POST /runs` 已经返回，HTTP 连接已经结束，`run_id` 如何能在几分钟后被查询、在 Worker 崩溃后被恢复呢？"},{"id":"p49","kind":"paragraph","text":"要解决这个问题，Run 就不能只活在某个 Web 进程或 Worker 的内存里，它必须被持久化到数据库中。","raw":"要解决这个问题，Run 就不能只活在某个 Web 进程或 Worker 的内存里，它必须被持久化到数据库中。"},{"id":"p50","kind":"heading","text":"数据库设计：两本账与八张表","raw":"## 数据库设计：两本账与八张表"},{"id":"p51","kind":"paragraph","text":"既然 Run 是一项长期存在的工作，数据库就不能只保存最终回答。","raw":"既然 Run 是一项长期存在的工作，数据库就不能只保存最终回答。"},{"id":"p52","kind":"paragraph","text":"假设 messages 表中只有一句：「已为您退款 39.9 元。」我们依然不知道 Agent 先查了什么、调用了几次模型、退款工具是否真的成功、Worker 是否中途崩溃、消耗了多少 Token，也不知道是谁批准了这次敏感操作。","raw":"假设 messages 表中只有一句：「已为您退款 39.9 元。」我们依然不知道 Agent 先查了什么、调用了几次模型、退款工具是否真的成功、Worker 是否中途崩溃、消耗了多少 Token，也不知道是谁批准了这次敏感操作。"},{"id":"p53","kind":"paragraph","text":"Chatbot 的 messages 主要记录「人和 AI 说了什么」；Agent Harness 还必须记录「系统做了什么，以及它是如何做到的」。","raw":"Chatbot 的 messages 主要记录「人和 AI 说了什么」；**Agent Harness 还必须记录「系统做了什么，以及它是如何做到的」。**"},{"id":"p54","kind":"paragraph","text":"为了回答这些问题，数据库首先需要记录两类性质不同的事实，也就是「两本账」。","raw":"为了回答这些问题，数据库首先需要记录两类性质不同的事实，也就是「两本账」。"},{"id":"p55","kind":"heading","text":"1、两本账：状态（现在在哪）vs 事件（怎么走到这里）","raw":"### 1、两本账：状态（现在在哪）vs 事件（怎么走到这里）"},{"id":"p56","kind":"paragraph","text":"第一本账：主状态（Status）。它是覆盖式的，记录一次执行当下的最终形态。从 queued 变为 running，再变为 succeeded，数据库里始终只有一行记录在更新，回答的是「当前在哪一步」。","raw":"**第一本账：主状态（Status）**。它是覆盖式的，记录一次执行当下的最终形态。从 queued 变为 running，再变为 succeeded，数据库里始终只有一行记录在更新，回答的是「当前在哪一步」。"},{"id":"p57","kind":"paragraph","text":"第二本账：事件流（Events）。它是追加式的，记录了执行过程中的一切变迁。run.created、tool.started、model.completed，这些事件像日志一样单调递增，绝不修改历史。它回答的是「我们是如何走到这一步的」，是前端断线补发和系统复盘的核心依据。","raw":"**第二本账：事件流（Events）**。它是追加式的，记录了执行过程中的一切变迁。`run.created`、`tool.started`、`model.completed`，这些事件像日志一样单调递增，绝不修改历史。它回答的是「我们是如何走到这一步的」，是前端断线补发和系统复盘的核心依据。"},{"id":"p58","kind":"paragraph","text":"有了这两个记账原则，下一步才是决定：一次 Run 中的步骤、模型调用、工具副作用、恢复现场、成本和责任，分别应该记到哪里。","raw":"有了这两个记账原则，下一步才是决定：一次 Run 中的步骤、模型调用、工具副作用、恢复现场、成本和责任，分别应该记到哪里。"},{"id":"p59","kind":"heading","text":"2、拆解八张表：工程化事实的立体切面","raw":"### 2、拆解八张表：工程化事实的立体切面"},{"id":"p60","kind":"paragraph","text":"这八张表的设计，让每类数据只回答一个问题：","raw":"这八张表的设计，让每类数据只回答一个问题："},{"id":"p61","kind":"list","text":"1. agent_runs（主状态表）：系统的绝对中心。保存 run_id、当前 status、多租户 org_id、幂等键以及 input/output。其他所有表都通过 run_id 外键挂载在它之下。\n2. run_events（事件日志表）：不可变的追加式流水。核心字段是 sequence（单调递增序号）。当网络抖动前端断开，重连时只需带上 Last-Event-ID，后端即可通过 sequence > N 瞬间补发丢失的事件帧。\n3. agent_steps（步骤表）：将漫长的执行划分为可观测的阶段（如查询订单、决策退款、撰写回复）。相比于密集的 Event，Step 提供了更清晰的宏观执行视角。\n4. model_calls（模型调用表）：每一次对 LLM 的请求都独立落库。不仅记录完整的 prompt 和 completion 上下文，更重要的是记录 prompt_tokens、completion_tokens 以及 latency_ms，为延迟分析和模型评估提供原始数据。\n5. tool_calls（工具调用表）：管理副作用的最关键表格。记录参数 args_json 与返回结果 result_json。有副作用的工具（如退款 API）在此表必须记录 idempotency_key，以保证下游外部系统不会因为 Worker 重试而发生重复扣款。\n6. checkpoints（恢复点表）：崩溃自愈的存档点。在工具调用完成或进入挂起状态时，将当前上下文写入 state_json。一旦当前 Worker 进程意外崩溃死亡，调度器会将任务派发给新 Worker，新 Worker 直接读取最近的 Checkpoint 即可进行断点续传，而不必从头重新请求大模型。\n7. usage_records（用量账单表）：成本聚合视角。用于统计某个租户、某个时间段内总共消耗了多少 Token 与真金白银。\n8. audit_logs（审计日志表）：安全合规的底线。并非所有事件都是审计日志，诸如 refund_order 等涉及资金、权限的高危操作，必须单独记入审计表，回答「谁在什么时间、触发了什么敏感动作」。","raw":"1. **`agent_runs`（主状态表）**：系统的绝对中心。保存 `run_id`、当前 status、多租户 `org_id`、幂等键以及 input/output。其他所有表都通过 `run_id` 外键挂载在它之下。\n2. **`run_events`（事件日志表）**：不可变的追加式流水。核心字段是 `sequence`（单调递增序号）。当网络抖动前端断开，重连时只需带上 Last-Event-ID，后端即可通过 `sequence > N` 瞬间补发丢失的事件帧。\n3. **`agent_steps`（步骤表）**：将漫长的执行划分为可观测的阶段（如查询订单、决策退款、撰写回复）。相比于密集的 Event，Step 提供了更清晰的宏观执行视角。\n4. **`model_calls`（模型调用表）**：每一次对 LLM 的请求都独立落库。不仅记录完整的 prompt 和 completion 上下文，更重要的是记录 `prompt_tokens`、`completion_tokens` 以及 `latency_ms`，为延迟分析和模型评估提供原始数据。\n5. **`tool_calls`（工具调用表）**：管理副作用的最关键表格。记录参数 `args_json` 与返回结果 `result_json`。有副作用的工具（如退款 API）在此表必须记录 `idempotency_key`，以保证下游外部系统不会因为 Worker 重试而发生重复扣款。\n6. **`checkpoints`（恢复点表）**：崩溃自愈的存档点。在工具调用完成或进入挂起状态时，将当前上下文写入 `state_json`。一旦当前 Worker 进程意外崩溃死亡，调度器会将任务派发给新 Worker，新 Worker 直接读取最近的 Checkpoint 即可进行断点续传，而不必从头重新请求大模型。\n7. **`usage_records`（用量账单表）**：成本聚合视角。用于统计某个租户、某个时间段内总共消耗了多少 Token 与真金白银。\n8. **`audit_logs`（审计日志表）**：安全合规的底线。并非所有事件都是审计日志，诸如 `refund_order` 等涉及资金、权限的高危操作，必须单独记入审计表，回答「谁在什么时间、触发了什么敏感动作」。"},{"id":"p62","kind":"paragraph","text":"至此，同一个 run_id 终于把状态、轨迹、调用、恢复、成本与审计串成了一条完整证据链。","raw":"至此，同一个 `run_id` 终于把状态、轨迹、调用、恢复、成本与审计串成了一条完整证据链。"},{"id":"p63","kind":"heading","text":"3、两本账必须从 Run 诞生时就保持一致","raw":"### 3、两本账必须从 Run 诞生时就保持一致"},{"id":"p64","kind":"paragraph","text":"数据库设计的最后一个关键，不是多建一张表，而是保证相关事实不会出现「半成功」。","raw":"数据库设计的最后一个关键，不是多建一张表，而是保证相关事实不会出现「半成功」。"},{"id":"p65","kind":"paragraph","text":"处理 POST /runs 时，系统至少要同时写入：","raw":"处理 `POST /runs` 时，系统至少要同时写入："},{"id":"p66","kind":"list","text":"- agent_runs 中的 queued 主记录\n- run_events 中的第一条 run.created 事件","raw":"- `agent_runs` 中的 queued 主记录\n- `run_events` 中的第一条 `run.created` 事件"},{"id":"p67","kind":"paragraph","text":"这两个写入描述的是同一个业务事实，因此必须放在同一个事务中：要么一起提交，要么一起回滚。","raw":"这两个写入描述的是同一个业务事实，因此必须放在同一个事务中：要么一起提交，要么一起回滚。"},{"id":"p68","kind":"paragraph","text":"所以，事务不是数据库章节末尾的附加知识，而是连接 Run API 和两本账的最后一环：POST /runs 只有在 Run 身份与第一条历史同时落库后，才算真正创建成功。","raw":"所以，事务不是数据库章节末尾的附加知识，而是连接 Run API 和两本账的最后一环：`POST /runs` 只有在 Run 身份与第一条历史同时落库后，才算真正创建成功。"},{"id":"p69","kind":"heading","text":"最后","raw":"## 最后"},{"id":"p70","kind":"paragraph","text":"现在再回头看整条链路：","raw":"现在再回头看整条链路："},{"id":"p71","kind":"list","text":"- 用户发起请求后，Run API 没有让 HTTP 一直等待，而是创建一个拥有 run_id 的长期资源\n- 数据库用状态账回答「现在在哪」，用事件账保存「如何走到这里」\n- 八张表再把步骤、模型、工具、恢复、成本与审计补充完整","raw":"- 用户发起请求后，Run API 没有让 HTTP 一直等待，而是创建一个拥有 `run_id` 的长期资源\n- 数据库用状态账回答「现在在哪」，用事件账保存「如何走到这里」\n- 八张表再把步骤、模型、工具、恢复、成本与审计补充完整"},{"id":"p72","kind":"paragraph","text":"它们共同完成了一次关键转变：后端不再只响应一次请求，而是在持续管理一份可查询、可取消、可恢复、可观察的工作。","raw":"它们共同完成了一次关键转变：**后端不再只响应一次请求，而是在持续管理一份可查询、可取消、可恢复、可观察的工作。**"},{"id":"p73","kind":"paragraph","text":"当然，Run 现在只是被创建并可靠地保存了，它仍然停留在 queued 状态。接下来，是谁把任务投递出去？Worker 如何安全领取并推进状态机？投递失败后又怎样重试和兜底？","raw":"当然，Run 现在只是被创建并可靠地保存了，它仍然停留在 queued 状态。接下来，是谁把任务投递出去？Worker 如何安全领取并推进状态机？投递失败后又怎样重试和兜底？"},{"id":"p74","kind":"paragraph","text":"下一篇文章，我会继续沿着同一条 Run 的生命线，拆解异步队列、Dispatcher 调度以及 Worker 运行时。","raw":"下一篇文章，我会继续沿着同一条 Run 的生命线，拆解异步队列、Dispatcher 调度以及 Worker 运行时。"}],"authored_claims":[{"id":"request-and-execution-must-decouple","text":"Run API 的设计哲学是「请求归请求，执行归执行」。API 的作用不是把任务跑完，而是建立契约：校验后在数据库登记一张工单，毫秒级返回 run_id，把控制权交还前端。同步等待在真实业务中完全不可行。","stance":"judgment","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-07-27"},{"id":"idempotency-is-the-only-answer-to-double-refund","text":"幂等设计是「网络抖动导致重复退款」的唯一解法。对 org_id 和 idempotency_key 建唯一联合约束，相同键加相同 Input 直接返回原 run_id，相同键但不同 Input 返回 409。重复请求本身不是错误，是分布式系统的常态。","stance":"judgment","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-07-27"},{"id":"two-ledgers-status-and-events","text":"数据库要记两类性质不同的事实。主状态是覆盖式的，始终只有一行在更新，回答「当前在哪一步」；事件流是追加式的，单调递增绝不修改历史，回答「我们是如何走到这一步的」，也是断线补发和事后复盘的依据。","stance":"fact","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-07-27"},{"id":"sequence-enables-reconnect-replay","text":"run_events 的 sequence 单调递增序号是断线重连的关键。前端重连时只需带上 Last-Event-ID，后端通过 sequence > N 即可瞬间补发丢失的事件帧。","stance":"fact","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-07-27"},{"id":"agent-db-records-how-not-just-what","text":"Chatbot 的 messages 记录「人和 AI 说了什么」，Agent Harness 还必须记录「系统做了什么，以及它是如何做到的」。只存一句「已为您退款 39.9 元」，你不知道它查了什么、调了几次模型、退款是否真的成功、消耗了多少 Token、谁批准了这次操作。","stance":"judgment","confidence":"high","supersedes":null,"note":null,"formed_at":"2026-07-27"}],"claim_ids":["request-and-execution-must-decouple","idempotency-is-the-only-answer-to-double-refund","two-ledgers-status-and-events","sequence-enables-reconnect-replay","agent-db-records-how-not-just-what"],"revision":"75e183bf179fa245039ad25831e5ad3353950dac99f9a6f32dca3c082c876160"}