智能体执行到一半崩了怎么办:用检查点与幂等号实现断点续跑

简介: 本文提出智能体生产化落地的核心方案:将内存中易崩溃的`while`循环,重构为基于数据库的可持久化执行轨迹。通过`runs/steps/effects`三张表实现状态检查点、透传幂等号防重复扣款、失败任务原样重放,解决状态丢失、副作用重复、问题难追溯三大痛点,让智能体真正“跑得稳、退得准、查得清”。

一、背景:Demo 跑得欢,生产不敢上

先看一个很常见的场景:售后工单智能体。用户提交退款申请后,智能体需要查询订单、判断是否满足退款条件、调用退款接口、写入工单备注、最后给用户回一条结论文案。整个过程要经过多轮模型推理和多次外部接口调用,耗时从几十秒到几分钟不等。

在本地 Demo 里,这段逻辑是一个干净的 while 循环:调模型、解析动作、执行工具、把结果拼回上下文。但放到线上,它会在下面三个地方出问题。

第一,状态只存在内存里。 一次任务要调三四次模型,输入 token 累积到上万。只要中间进程被重启、容器被调度、或者上游网关超时重试,整个循环就得从头再跑一遍,前面的 token 白花,延迟也翻倍。

第二,副作用容易重复。 这是最危险的。智能体调用完退款接口、还没来得及把结果写回上下文时进程挂了,重试时它会认为退款没有发生,于是再退一次。真金白银的损失。

第三,出问题无法回溯。 现场只留下一段 print 日志,既不知道模型在第几轮做了什么判断,也没法把某次失败的任务原样重放一遍来定位。

这三个问题本质上指向同一件事:智能体的执行过程需要变成可持久化、可重放、可去重的数据,而不是一段转瞬即逝的控制流。

二、方案设计:把循环切成可落库的步骤

核心思路是:把智能体的执行循环从「内存里的 while」改造成「数据库里一条条可重放的状态记录」。每一次模型决策、每一次工具调用都是一个步骤(Step),步骤执行前后都落库,形成执行轨迹(Trace)。

整体流程如下:

用户目标
  │
  ▼
┌──────────────────────────────────────────────┐
│ run_agent(run_id)                            │
│  1. 读 runs / steps,恢复上下文              │
│  2. 重做 status=pending 的步骤(幂等去重)   │
│  3. planner(goal, history) 产出下一步动作    │
│  4. 先写 steps(pending) → 执行 → 回填结果    │
│  5. finish 落库,run 置 done                 │
└──────────────────────────────────────────────┘
  │
  ▼
执行轨迹:goal → get_order → apply_refund → finish

对应的三张表:

表名 作用 关键字段
runs 一次任务的元信息 run_id、goal、status
steps 每一步的输入输出,检查点 run_id、seq、payload、output、status
effects 有副作用工具的幂等记录 idem_key、tool、args、output

三个关键机制:

  1. 检查点(Checkpoint):每步执行前先写 pending,执行后回填 output 并置 done。进程在任何位置崩溃,都能从 steps 里还原出「做到哪一步了」。
  2. 幂等号(Idempotency Key):由「工具名 + 规范化参数」哈希得到,作为业务幂等号透传给下游接口。同一个幂等号重复调用,下游只会真正执行一次。
  3. 重放(Replay):重启后用同一个 run_id 再次调用 run_agent,它会先重做遗留的 pending 步骤,再继续往下走,已完成步骤不再重复推理。

三、实现步骤

  1. 建表:steps 以 (run_id, seq) 为主键,保证同一任务步骤序号唯一且可幂等写入。
  2. 封装幂等调用:所有工具统一走 call_tool_idempotent,先查 effects 缓存,未命中则先占位(写入 inflight 记录),再执行下游,最后回填结果。
  3. 组装执行循环:把「重做 pending」「调 planner」「写意图」「执行」「回填」串成一个可重入函数。
  4. 接入模型 planner:用阿里云百炼的 OpenAI 兼容接口调用通义千问,强制输出 JSON 动作;本地调试时用规则版 planner 替代,方便无密钥验证。
  5. 故障注入验证:用一个环境变量在指定工具执行后强制退出进程,再重跑同一个 run_id,观察下游是否被重复调用。

四、代码实现

以下代码用 SQLite 做存储,便于直接运行;生产环境把 sqlite3.connect 换成 RDS MySQL 的连接即可,SQL 语法基本通用。

import hashlib
import json
import os
import sqlite3
import time
import uuid

DB_PATH = 'agent_state.db'

DDL = '''
CREATE TABLE IF NOT EXISTS runs(
  run_id TEXT PRIMARY KEY, goal TEXT NOT NULL,
  status TEXT NOT NULL, created_at REAL NOT NULL);
CREATE TABLE IF NOT EXISTS steps(
  run_id TEXT NOT NULL, seq INTEGER NOT NULL, kind TEXT NOT NULL,
  payload TEXT NOT NULL, output TEXT, status TEXT NOT NULL,
  PRIMARY KEY(run_id, seq));
CREATE TABLE IF NOT EXISTS effects(
  idem_key TEXT PRIMARY KEY, tool TEXT NOT NULL, args TEXT NOT NULL,
  output TEXT, created_at REAL NOT NULL);
'''


def get_conn():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.executescript(DDL)
    return conn


def make_idem_key(tool, args):
    # 参数必须规范化排序,否则 {'a':1,'b':2} 和 {'b':2,'a':1} 会算出两个幂等号
    raw = tool + ':' + json.dumps(args, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:32]


# ---------- 工具层:统一签名 fn(args, idem_key) ----------
def get_order(args, idem_key):
    return {
   'order_id': args['order_id'], 'amount': 19900, 'status': 'paid'}


def apply_refund(args, idem_key):
    amt = '%.2f' % (args['amount'] / 100.0)
    print('[下游副作用] 退款 %s 元, 幂等号=%s' % (amt, idem_key))
    return {
   'order_id': args['order_id'], 'refunded': args['amount'],
            'refund_no': 'R' + idem_key[:8]}


TOOLS = {
   'get_order': get_order, 'apply_refund': apply_refund}


def call_tool_idempotent(conn, tool, args):
    idem_key = make_idem_key(tool, args)
    row = conn.execute('SELECT output FROM effects WHERE idem_key=?', (idem_key,)).fetchone()
    if row is not None and row['output']:
        return json.loads(row['output']), 'hit'
    # 先占位:把幂等号提前登记,相当于把请求号预先告知下游
    conn.execute('INSERT OR IGNORE INTO effects(idem_key, tool, args, output, created_at) '
                 'VALUES(?,?,?,?,?)',
                 (idem_key, tool, json.dumps(args, ensure_ascii=False), None, time.time()))
    conn.commit()
    out = TOOLS[tool](args, idem_key)
    if os.environ.get('CRASH_AFTER') == tool:
        raise SystemExit('[故障注入] 下游已执行,进程在回填结果前崩溃')
    conn.execute('UPDATE effects SET output=? WHERE idem_key=?',
                 (json.dumps(out, ensure_ascii=False), idem_key))
    conn.commit()
    return out, 'miss'


# ---------- 检查点读写 ----------
def save_step(conn, run_id, seq, kind, payload, output, status):
    conn.execute('''INSERT INTO steps(run_id, seq, kind, payload, output, status)
                    VALUES(?,?,?,?,?,?)
                    ON CONFLICT(run_id, seq) DO UPDATE SET
                    output=excluded.output, status=excluded.status''',
                 (run_id, seq, kind,
                  json.dumps(payload, ensure_ascii=False),
                  json.dumps(output, ensure_ascii=False) if output is not None else None,
                  status))
    conn.commit()


def load_steps(conn, run_id):
    rows = conn.execute('SELECT seq, kind, payload, output, status FROM steps '
                        'WHERE run_id=? ORDER BY seq', (run_id,)).fetchall()
    return [{
   'seq': r['seq'], 'kind': r['kind'],
             'payload': json.loads(r['payload']),
             'output': json.loads(r['output']) if r['output'] else None,
             'status': r['status']} for r in rows]


# ---------- 可重入的执行循环 ----------
def run_agent(goal, planner, max_steps=8, run_id=None):
    conn = get_conn()
    run_id = run_id or uuid.uuid4().hex[:12]
    row = conn.execute('SELECT status FROM runs WHERE run_id=?', (run_id,)).fetchone()
    if row is None:
        conn.execute('INSERT INTO runs VALUES(?,?,?,?)',
                     (run_id, goal, 'running', time.time()))
        conn.commit()
    elif row['status'] == 'done':
        return {
   'run_id': run_id, 'status': 'done', 'replayed': True}

    # 1) 重做上次没跑完的步骤,靠幂等号保证副作用只发生一次
    for s in load_steps(conn, run_id):
        if s['status'] == 'pending':
            act = s['payload']
            out, src = call_tool_idempotent(conn, act['tool'], act['args'])
            save_step(conn, run_id, s['seq'], 'tool_call', act,
                      {
   'output': out, 'cache': src}, 'done')

    steps = load_steps(conn, run_id)
    seq = len(steps)
    while seq < max_steps:
        act = planner(goal, steps)
        if act['type'] == 'finish':
            save_step(conn, run_id, seq, 'finish', act, act, 'done')
            conn.execute('UPDATE runs SET status=? WHERE run_id=?', ('done', run_id))
            conn.commit()
            return {
   'run_id': run_id, 'status': 'done',
                    'answer': act.get('answer'), 'steps': seq + 1}
        # 2) 先记录意图再执行:任何时刻崩溃都有据可查
        save_step(conn, run_id, seq, 'tool_call', act, None, 'pending')
        out, src = call_tool_idempotent(conn, act['tool'], act['args'])
        done_step = {
   'output': out, 'cache': src}
        save_step(conn, run_id, seq, 'tool_call', act, done_step, 'done')
        steps.append({
   'seq': seq, 'kind': 'tool_call', 'payload': act,
                      'output': done_step, 'status': 'done'})
        seq += 1

    conn.execute('UPDATE runs SET status=? WHERE run_id=?', ('aborted', run_id))
    conn.commit()
    return {
   'run_id': run_id, 'status': 'aborted'}


# ---------- 规则版 planner,便于本地无密钥验证 ----------
def rule_planner(goal, steps):
    done = [s['payload'].get('tool') for s in steps
            if s['kind'] == 'tool_call' and s['status'] == 'done']
    if 'get_order' not in done:
        return {
   'type': 'tool', 'thought': '先核对订单状态',
                'tool': 'get_order', 'args': {
   'order_id': 'A1001'}}
    if 'apply_refund' not in done:
        return {
   'type': 'tool', 'thought': '订单已支付,发起全额退款',
                'tool': 'apply_refund',
                'args': {
   'order_id': 'A1001', 'amount': 19900}}
    return {
   'type': 'finish', 'answer': '已为订单 A1001 退款 199.00 元'}


if __name__ == '__main__':
    r1 = run_agent('帮用户办理订单 A1001 的退款', rule_planner)
    print('第一次执行:', r1)
    r2 = run_agent('帮用户办理订单 A1001 的退款', rule_planner, run_id=r1['run_id'])
    print('相同 run_id 重放:', r2)

关键步骤说明:

  • save_step 用 ON CONFLICT(run_id, seq) DO UPDATE 做 upsert,同一个步骤既可以先写 pending 再回填,也可以被重放逻辑覆盖,语义一致。
  • call_tool_idempotent 里的「先占位再执行」是关键顺序。如果反过来先执行再落库,崩溃窗口期就会留下「下游已执行、本地无记录」的脏状态。
  • 幂等号通过 make_idem_key 的 sort_keys=True 保证参数顺序不影响结果,这是幂等最容易踩的坑之一。
  • 主循环里 seq = len(steps) 而不是自增计数器,这样重启后序号天然接续。

验证断点续跑:先人为制造崩溃,再重跑同一任务。

CRASH_AFTER=apply_refund python agent_runtime.py
# 输出:[下游副作用] 退款 199.00 元, 幂等号=xxxx
#       [故障注入] 下游已执行,进程在回填结果前崩溃

python agent_runtime.py

第二次运行时会发现 steps 里第 2 步仍是 pending,于是先走重做分支。此时 effects 表里该幂等号的 output 为空,代码会再次调用下游,但因为携带的是同一个幂等号,真实下游(如退款网关的 out_request_no)只会返回第一次的结果,不会重复退款。这也解释了为什么幂等号必须透传给下游,而不能只依赖本地缓存表。

生产环境把规则版 planner 换成模型版即可:

def llm_planner(goal, steps):
    from openai import OpenAI
    client = OpenAI(api_key=os.environ['DASHSCOPE_API_KEY'],
                    base_url='https://dashscope.aliyuncs.com/compatible-mode/v1')
    context = json.dumps({
   'goal': goal, 'history': steps}, ensure_ascii=False)
    resp = client.chat.completions.create(
        model='qwen-plus',
        messages=[{
   'role': 'system', 'content': SYSTEM_PROMPT},
                  {
   'role': 'user', 'content': context}],
        response_format={
   'type': 'json_object'},
        temperature=0)
    return json.loads(resp.choices[0].message.content)

SYSTEM_PROMPT 里只需约定两件事:输出必须是 JSON 对象;type 取 tool 时必带 tool 与 args,取 finish 时必带 answer。配合 response_format 和 temperature=0,输出稳定性可以满足生产要求。

五、注意事项

  1. 幂等窗口无法用本地表彻底消除。 「下游执行成功」和「本地回填结果」之间永远存在空隙,唯一可靠的做法是把幂等号作为业务请求号传给下游,由下游去重。本地 effects 表只是加速和兜底。
  2. 同一 run_id 必须加锁。 两个 worker 同时重放同一个任务,会并发调用下游。用 Redis 分布式锁或数据库行锁(SELECT ... FOR UPDATE)包住整个 run_agent。
  3. 控制轨迹体积。 工具返回的原始报文可能几十 KB,全量塞进 steps 会让表迅速膨胀。建议对 output 做截断,大对象放 OSS 只存 URI,并给 steps、effects 按月分区或设置 TTL。
  4. max_steps 是成本保险丝。 模型陷入循环时,没有步数上限的智能体会持续消耗 token,超限后置 aborted 并告警,比自然跑完更安全。
  5. 敏感信息脱敏后再落库。 手机号、地址、卡号等字段在写入 steps 前要脱敏,执行轨迹往往会被拿来做人工复盘,间接扩大了敏感数据的暴露面。
  6. 重放不要重复推理。 恢复上下文时直接使用 steps 里已缓存的模型决策,只对 pending 之后的步骤重新调模型,否则断点续跑省下的 token 又会被抵消掉。

六、总结

智能体从能跑通到敢上线,差的往往不是模型能力,而是执行过程的可控性。本文把 ReAct 循环拆成可落库的步骤,用检查点解决「崩了要重来」,用透传幂等号解决「副作用重复」,用执行轨迹解决「出事查不到」。三张表、一个可重入函数,就能让智能体具备断点续跑的能力。

在此基础上继续往下走,还可以把 steps 扩展成完整的可观测体系:记录每步的模型耗时与 token 消耗做成本分析,把失败轨迹沉淀成评测集做回归测试,或者让多个智能体共享同一套检查点机制来编排长任务。执行轨迹本身,就是智能体工程化最有价值的资产之一。

相关文章
|
云安全 安全 网络安全
|
7天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
2018 1
|
15天前
|
JSON 程序员 数据格式
最新版Notepad++下载、安装、配置保姆级教程(附官网安装包)
Notepad++ 是一款免费开源、轻量快速的 Windows 文本编辑器,支持多标签、语法高亮、代码折叠与宏录制,体积小、启动快、资源占用低,兼容 x64/x32/ARM 架构,是程序员和文字工作者的高效利器。
3374 0
|
14天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
2365 44
|
11天前
|
Web App开发 人工智能 前端开发
2026 年 AI 浏览器怎么选?Ego Lite、Tabbit、Chrome DevTools MCP 深度对比,开发者到底该用哪个?
2026年AI浏览器选型指南:Ego Lite专注多Agent协同(Mac优先),Tabbit国产首选,原生支持Windows与Codex控制;Chrome DevTools MCP非浏览器而是开发者利器,深度集成Console/Network/Performance调试能力,让AI真正“看懂并验证”前端代码。
1447 3
|
8天前
|
安全 应用服务中间件 Go
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
PHP2Shell 双 RCE 已遭在野利用:WordPress、Drupal 全在射程内,nginx 其实另有洞。拆成因与修复顺序
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
|
8天前
|
开发者 索引
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
Open Plugin Market 是一个开放插件索引平台,收录800+优质DSH插件,支持分类浏览、智能推荐与随机发现。无需安装市场,粘贴索引地址即可一键接入,网页版直访 dshopen.org,助力小开发者被看见。(239字)
1835 1
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
|
6天前
|
人工智能 Linux API
Magpie, 轻量本地AI网关!免费Token用不完!
Magpie 是开源AI编程模型调度网关,上线一月获1.8万Star。它通过本地高性能网关统一托管多平台API密钥、账号与免费额度,实现跨Agent的模型秒级切换、静默额度轮换与资源复用,显著降低碎片化管理成本。(239字)
Magpie, 轻量本地AI网关!免费Token用不完!
|
6天前
|
安全 程序员 应用服务中间件
PHP 被曝两大致命漏洞,请所有程序员抓紧做这件事!
PHP 官方发布了紧急安全更新,一口气修复了两个严重漏洞。如果网站和服务器用了 PHP 应该怎么处理?漏洞的原理是什么?本文给你讲清楚
720 0

热门文章

最新文章