AI Agent 跑长任务,不能只靠一个 async:AgentTask 和 AgentStep 状态机实战

22 阅读27分钟

上一篇讲的是工具调用边界。

核心观点是:Agent 不是能调用越多工具越好,而是要知道哪些能力可以读,哪些能力只能生成建议,哪些写入必须经过业务命令、提案机制和版本校验。

工具边界拆清楚之后,接下来会遇到一个更工程的问题:

Agent 任务到底怎么跑?

很多 Agent Demo 会把执行过程写成一次函数调用:

用户点击按钮
后端 async 调模型
模型返回结果
页面展示

这个模型适合 Demo。

但一旦进入真实业务系统,就不够了。

因为真实的 Agent 任务不是一次模型调用。

它可能要经历:

加载上下文
检查章节计划
推荐写作技能
生成草稿
创建提案
等待用户审核
继续下一批章节
失败后重试
取消运行中任务
服务重启后恢复状态
前端持续展示进度

这些能力靠一个 async def run() 是撑不住的。

所以这一篇继续讲实战:我是怎么用 AgentTaskAgentStep 把 Agent 长任务做成可持久化、可观察、可恢复的状态机。

先说结论

在当前系统里,我把 Agent 执行状态拆成两层:

AgentTask:任务总账,记录这个 Agent 任务是什么、当前到哪、最终结果是什么
AgentStep:步骤明细,记录任务内部每一步的状态、输入、输出和错误

如果只用一句话概括:

AgentTask 解决“这个任务是否还活着”,AgentStep 解决“这个任务具体卡在哪”。

这两个表配合后,系统才能回答这些问题:

任务是等待中、执行中、待审核、失败、取消,还是已中断?
任务执行到哪一步了?
当前步骤有没有输入输出?
任务失败后能不能继续?
服务重启后哪些任务需要标记中断?
页面刷新后用户还能不能看到进度?
同一个按钮重复点击会不会创建多条任务?

这也是我觉得 Agent 从 Demo 进入产品时必须补上的一层。

模型负责推理,状态机负责让推理过程进入业务系统。

为什么不能只靠一次 async 调用

最开始做 AI 功能时,很容易写出这种代码:

async def write_chapter(req):
    context = await build_context(req.novel_id)
    content = await call_model(context)
    await save_chapter(content)
    return content

这个写法最大的问题不是不能跑。

而是它跑起来之后,系统几乎回答不了任何工程问题。

比如:

模型调用 90 秒,前端怎么知道它还在跑?
用户点了取消,后端怎么安全停下?
生成草稿成功了,保存提案失败了,重试从哪里开始?
服务重启时,运行中的任务是什么状态?
任务失败了,错误发生在上下文、模型、保存还是审核?
用户刷新页面后,怎么恢复刚才的任务进度?
同一个请求因为网络重试打了两次,会不会重复执行?

这些问题不是 prompt 能解决的。

它们需要任务状态。

所以这次项目里,我没有把 Agent 设计成“一个接口返回最终结果”,而是把它设计成“任务 + 步骤 + 命令 + worker”的结构。

大概是这样:

前端发起命令
    ↓
创建 AgentTask
    ↓
创建 AgentStep
    ↓
worker claim 任务
    ↓
每一步更新 AgentStep
    ↓
任务完成/失败/待审核/取消
    ↓
前端轮询任务和步骤

这就是本篇要讲的任务状态机。

第一层:AgentTask 是任务总账

先看数据模型。

AgentTask 不是简单的任务表。

它承载了几个关键职责:

任务类型:这是写章节、自动写作、批量修复、质检循环,还是记忆提取
任务状态:pending/running/pending_review/completed/failed/cancelled/interrupted
业务输入:target_chapter、input、context_snapshot_id
执行结果:result、error
幂等控制:request_key、dedupe_key、retry_operation_key
长任务控制:lease_token、lease_expires_at、heartbeat_at、attempt
任务关系:parent_task_id、retry_of_task_id、retry_generation、batch_index
取消信号:cancel_requested_at

项目里的模型大概是这样:

class AgentTask(Base):
    """Agent 编排任务"""
    __tablename__ = "agent_tasks"

    id = Column(Integer, primary_key=True, autoincrement=True)
    novel_id = Column(Integer, ForeignKey("novels.id"), nullable=False, index=True)
    task_type = Column(String(50), default="write_chapter_loop", index=True)
    status = Column(String(20), default="pending", index=True)
    target_chapter = Column(Integer, default=0)
    auto_fix = Column(Boolean, default=False)
    input = Column(JSON, default=dict)
    result = Column(JSON, default=dict)
    error = Column(Text, default="")
    created_at = Column(DateTime, default=datetime.utcnow)
    started_at = Column(DateTime, nullable=True)
    finished_at = Column(DateTime, nullable=True)
    dedupe_key = Column(String(128), unique=True, nullable=True)
    lease_token = Column(String(64), nullable=True)
    lease_expires_at = Column(DateTime, nullable=True)
    heartbeat_at = Column(DateTime, nullable=True)
    attempt = Column(Integer, nullable=False, default=0)
    context_snapshot_id = Column(
        Integer,
        ForeignKey("story_context_snapshots.id"),
        nullable=True,
        index=True,
    )
    origin_chapter_id = Column(Integer, nullable=True, index=True)
    parent_task_id = Column(Integer, ForeignKey("agent_tasks.id"), nullable=True, index=True)
    batch_index = Column(Integer, nullable=True)
    retry_of_task_id = Column(Integer, ForeignKey("agent_tasks.id"), nullable=True, index=True)
    request_key = Column(String(128), unique=True, nullable=True)
    retry_operation_key = Column(String(128), unique=True, nullable=True)
    retry_generation = Column(Integer, nullable=False, default=0)
    cancel_requested_at = Column(DateTime, nullable=True)

    __table_args__ = (
        Index(
            "uq_agent_tasks_parent_chapter_retry_generation",
            "parent_task_id",
            "target_chapter",
            "retry_generation",
            unique=True,
        ),
    )

这里最值得注意的不是字段多。

而是这些字段对应的是 Agent 落地时绕不开的工程问题。

status 让系统知道任务处在哪个阶段。

inputresult 让任务可追溯。

context_snapshot_id 把任务和上下文快照绑定,避免重试时读取实时数据。

lease_tokenheartbeat_at 让 worker 可以安全执行长任务。

request_keydedupe_key 解决重复点击、网络重试和并发创建。

retry_of_task_idretry_generation 让重试不是覆盖旧任务,而是形成链路。

这就是为什么我不建议只加一个 status 字段就结束。

Agent 任务的状态不是 UI 装饰,它是系统一致性的核心。

第二层:AgentStep 是过程明细

只有 AgentTask 还不够。

如果任务失败了,光知道它是 failed 没有太大帮助。

我们还需要知道:

失败发生在哪一步?
这一步开始了吗?
这一步输入是什么?
这一步输出了什么?
错误是什么?
前面哪些步骤已经完成?
重试时哪些步骤可以保留?

所以系统里有 AgentStep

class AgentStep(Base):
    """Agent 编排步骤日志"""
    __tablename__ = "agent_steps"

    id = Column(Integer, primary_key=True, autoincrement=True)
    task_id = Column(Integer, ForeignKey("agent_tasks.id"), nullable=False, index=True)
    step_key = Column(String(50), nullable=False)
    title = Column(String(100), default="")
    status = Column(String(20), default="pending", index=True)
    message = Column(Text, default="")
    input = Column(JSON, default=dict)
    output = Column(JSON, default=dict)
    error = Column(Text, default="")
    sort_order = Column(Integer, default=0)
    started_at = Column(DateTime, nullable=True)
    finished_at = Column(DateTime, nullable=True)

任务状态和步骤状态的关系可以这样理解:

AgentTask.status = 任务整体状态
AgentStep.status = 某一步局部状态

比如写章节任务的步骤定义是:

STEP_DEFINITIONS = [
    ("load_context", "加载上下文"),
    ("chapter_plan", "制定章节计划"),
    ("generate_draft", "生成章节草稿"),
    ("save_draft", "保存草稿"),
    ("review_chapter", "编辑审核"),
    ("fix_chapter", "自动修复"),
    ("extract_memory", "提取记忆"),
    ("finish_report", "生成报告"),
]

TASK_STEP_DEFINITIONS = {
    "write_chapter_loop": STEP_DEFINITIONS,
    "auto_write_pipeline": [
        ("plan_chapters", "规划章节"),
        ("write_chapters", "逐章写作"),
        ("finish_report", "生成报告"),
    ],
    "batch_fix": [
        ("scan_chapters", "扫描问题章节"),
        ("fix_chapters", "批量修复"),
        ("finish_report", "生成报告"),
    ],
    "quality_loop": [
        ("load_chapters", "加载章节"),
        ("loop_fix", "质检循环"),
        ("finish_report", "生成报告"),
    ],
    "autopilot": [
        ("decide", "自主决策"),
        ("execute", "执行动作"),
        ("finish_report", "生成报告"),
    ],
}

这段代码有一个好处:任务类型和步骤定义解耦。

同样是 Agent,不同任务可以有不同流程:

单章写作:上下文 -> 计划 -> 草稿 -> 提案 -> 报告
自动写作:规划章节 -> 逐章写作 -> 报告
质检循环:加载章节 -> 质检修复 -> 报告
自主驾驶:决策 -> 执行 -> 报告

这样做不会把所有 Agent 都塞进一个巨大的 run_agent() 里。

前端也能基于步骤定义展示任务进度。

创建任务时,先创建步骤

当用户发起一个 Agent 任务时,系统不是直接开始跑模型。

而是先创建 AgentTask,再为它创建默认步骤。

async def create_default_steps(db: AsyncSession, task_id: int) -> None:
    task = await db.get(AgentTask, task_id)
    definitions = TASK_STEP_DEFINITIONS.get(
        task.task_type if task else "",
        STEP_DEFINITIONS,
    )
    result = await db.execute(select(AgentStep).where(AgentStep.task_id == task_id))
    existing = {step.step_key: step for step in result.scalars().all()}
    for index, (step_key, title) in enumerate(definitions, start=1):
        if step_key in existing:
            step = existing[step_key]
            step.title = title
            step.sort_order = index
            continue
        db.add(
            AgentStep(
                task_id=task_id,
                step_key=step_key,
                title=title,
                sort_order=index,
            )
        )
    await db.commit()

这里有两个细节。

第一,步骤创建是幂等的。

如果步骤已经存在,就更新标题和排序,不重复插入。

第二,步骤先于执行存在。

这意味着任务还没开始跑,前端也可以展示完整的步骤骨架。

这对用户体验很重要。

用户点击“Agent 写下一章”之后,不应该只看到一个转圈。

他应该看到:

加载上下文:等待中
制定章节计划:等待中
生成章节草稿:等待中
保存草稿:等待中
生成报告:等待中

这就是“可观察”的第一步。

状态更新要收口成方法

有了步骤表之后,不要在业务代码里到处手写:

step.status = "running"
step.finished_at = ...
await db.commit()

否则一段时间后,状态字段会变得不一致。

所以项目里把步骤状态更新收口成几个方法:

async def mark_step_running(
    db: AsyncSession,
    step: AgentStep,
    message: str = "",
    input_data: dict[str, Any] | None = None,
) -> None:
    step.status = "running"
    step.message = message
    step.error = ""
    step.started_at = step.started_at or datetime.utcnow()
    step.finished_at = None
    if input_data is not None:
        step.input = input_data
    await db.commit()


async def mark_step_completed(
    db: AsyncSession,
    step: AgentStep,
    message: str = "",
    output: dict[str, Any] | None = None,
) -> None:
    step.status = "completed"
    step.message = message
    step.error = ""
    step.finished_at = datetime.utcnow()
    if output is not None:
        step.output = output
    await db.commit()


async def mark_step_failed(db: AsyncSession, step: AgentStep, error: str) -> None:
    step.status = "failed"
    step.error = error
    step.finished_at = datetime.utcnow()
    await db.commit()


async def mark_step_skipped(db: AsyncSession, step: AgentStep, message: str = "") -> None:
    step.status = "skipped"
    step.message = message
    step.error = ""
    step.finished_at = datetime.utcnow()
    await db.commit()

这几个方法看起来普通,但它们让状态语义稳定下来。

running:开始执行,记录 started_at
completed:成功完成,记录 output
failed:执行失败,记录 error
skipped:业务上跳过,但也算这个步骤已处理

skipped 很关键。

比如上一篇讲到,当前系统里写作结果先进入提案,不直接进入最终正文。

所以 review_chapterfix_chapterextract_memory 这些步骤会被跳过:

for step_key, message in (
    ("review_chapter", "等待作者应用提案后再进行编辑审核"),
    ("fix_chapter", "等待作者应用提案后再决定是否自动修复"),
    ("extract_memory", "等待作者应用并定稿后再提取章节记忆"),
):
    current_step = await get_step(db, task_id, step_key)
    if not is_step_done(current_step):
        await mark_step_skipped(db, current_step, message)

如果没有 skipped,前端会以为任务卡在后面几步。

所以状态机不是越简单越好,而是要能表达真实业务语义。

任务创建:幂等和并发先处理掉

再看任务创建。

真实系统里,用户点击按钮、前端重试、浏览器刷新、网络抖动,都可能导致同一个命令重复发送。

如果后端每次都插入一条 AgentTask,很快就会出现多个 Agent 同时写同一章。

所以任务创建不能只做 insert。

项目里创建任务时,会先处理:

operation_key 幂等回放
任务类型校验
章节目标校验
上下文快照绑定
active dedupe 锁
默认步骤创建

核心代码如下:

async def create_agent_task_command(
    db: AsyncSession,
    *,
    request: AgentTaskCreate,
    operation_key: str | None,
) -> AgentTask:
    """Create a root task, replaying the original root for the same request key."""
    request.target_chapter = canonical_batch_target(
        request.task_type,
        request.input or {},
        request.target_chapter,
    )
    payload = _create_payload(request)
    fingerprint = _fingerprint(payload)
    replay = await _replay_receipt(
        db,
        command="create",
        operation_key=operation_key,
        fingerprint=fingerprint,
    )
    if replay is not None:
        return replay

    root = await _replay_root_request(
        db,
        operation_key=operation_key,
        fingerprint=fingerprint,
    )
    if root is not None:
        return root

    if await db.get(Novel, request.novel_id) is None:
        raise AgentTaskCommandNotFound("novel_not_found")
    if request.task_type not in TASK_STEP_DEFINITIONS:
        raise AgentTaskCommandValidationError(f"unsupported_task_type:{request.task_type}")
    if request.task_type == "write_chapter_loop" and request.target_chapter <= 0:
        raise AgentTaskCommandValidationError("target_chapter_required")

    task_input = dict(request.input or {})
    if request.prompt:
        task_input["prompt"] = request.prompt

    task = await build_agent_task(
        db,
        novel_id=request.novel_id,
        task_type=request.task_type,
        status="pending",
        target_chapter=request.target_chapter,
        auto_fix=request.auto_fix,
        input=task_input,
        result={"_create_request_fingerprint": fingerprint},
        request_key=operation_key,
        dedupe_key=_active_dedupe_key(request),
    )
    try:
        db.add(task)
        await db.flush()
        await bind_task_context_snapshot(db, task, purpose="agent_task_create")
        await db.commit()
    except IntegrityError as error:
        await db.rollback()
        replay = await _replay_receipt(
            db,
            command="create",
            operation_key=operation_key,
            fingerprint=fingerprint,
        )
        if replay is not None:
            return replay
        raise AgentTaskCommandConflict("task_creation_conflict") from error

    await db.refresh(task)
    await create_default_steps(db, task.id)
    await db.refresh(task)
    return task

这段代码的重点不是“创建了一条任务”。

重点是:任务创建本身就是一个命令。

命令要有幂等键。

命令要能回放。

命令要能拒绝重复 active 任务。

命令要在任务开始前绑定上下文快照。

很多 Agent 系统在 Demo 阶段不处理这些问题,是因为只有一个用户、一个按钮、一次请求。

但进入真实业务后,这些都是基本盘。

worker 执行:用 lease 而不是相信内存

如果任务只在当前进程内跑,系统会非常脆弱。

进程挂了,任务状态丢了。

多个 worker 同时启动,可能抢同一个任务。

服务重启后,运行中的任务不知道该怎么办。

所以这里引入了一个常见但很实用的设计:lease。

worker 不直接拿任务跑,而是先 claim。

claim 成功后,任务从 pending 变成 running,同时写入 lease_tokenlease_expires_at

@dataclass(frozen=True)
class TaskClaim:
    task_id: int
    lease_token: str
    lease_expires_at: datetime


class AgentDispatcher:
    def __init__(
        self,
        session_factory: async_sessionmaker[AsyncSession],
        *,
        lease_seconds: int = 30,
    ):
        self._session_factory = session_factory
        self._lease_seconds = lease_seconds

    async def claim_next(
        self,
        worker_id: str,
        *,
        task_types: set[str] | frozenset[str] | None = None,
    ) -> TaskClaim | None:
        del worker_id
        async with self._session_factory() as session:
            query = select(AgentTask).where(
                AgentTask.status == "pending",
                AgentTask.created_at <= datetime.utcnow(),
                or_(
                    AgentTask.task_type.not_in(CHAPTER_MODEL_TASK_TYPES),
                    AgentTask.target_chapter <= 0,
                    AgentTask.context_snapshot_id.is_not(None),
                ),
            )
            if task_types is not None:
                query = query.where(AgentTask.task_type.in_(task_types))
            task = await session.scalar(query.order_by(AgentTask.id.asc()).limit(1))
            if task is None:
                return None

            now = datetime.utcnow()
            token = uuid4().hex
            expires_at = now + timedelta(seconds=self._lease_seconds)
            claimed = await session.execute(
                update(AgentTask)
                .where(AgentTask.id == task.id, AgentTask.status == "pending")
                .values(
                    status="running",
                    lease_token=token,
                    lease_expires_at=expires_at,
                    heartbeat_at=now,
                    attempt=AgentTask.attempt + 1,
                )
            )
            if claimed.rowcount != 1:
                await session.rollback()
                return None

            await session.commit()
            return TaskClaim(task.id, token, expires_at)

这里有一个很重要的点:

.where(AgentTask.id == task.id, AgentTask.status == "pending")

claim 不是先查再相信查询结果。

它要通过条件更新抢占任务。

如果两个 worker 同时查到同一条 pending 任务,只有一个 update 会成功。

这就是长任务执行里的第一道并发保护。

长任务要续租

Agent 任务经常会跑很久。

比如生成章节、质检多章、批量创建提案,都不是几百毫秒能结束的。

所以 worker claim 任务之后,还需要续租。

async def renew(self, task_id: int, lease_token: str) -> bool:
    async with self._session_factory() as session:
        now = datetime.utcnow()
        renewed = await session.execute(
            update(AgentTask)
            .where(
                AgentTask.id == task_id,
                AgentTask.status == "running",
                AgentTask.lease_token == lease_token,
            )
            .values(
                heartbeat_at=now,
                lease_expires_at=now + timedelta(seconds=self._lease_seconds),
            )
        )
        if renewed.rowcount != 1:
            await session.rollback()
            return False
        await session.commit()
        return True

续租必须带 lease_token

否则一个旧 worker 即使已经失去执行权,也可能继续更新任务。

这就像告诉系统:

不是任何人都能说“我还活着”
必须是当前持有 lease 的 worker 才能续租

后面完成任务、写入结果,也要带这个 token。

这对避免“旧 worker 写回旧结果”非常重要。

过期任务怎么恢复

有了 lease,就必须处理过期。

如果某个 worker 崩了,它不会再续租。

这时任务的 lease_expires_at 会过期。

系统可以把它恢复成可理解的状态。

async def recover_expired(self) -> int:
    async with self._session_factory() as session:
        expired_tasks = list(
            await session.scalars(
                select(AgentTask).where(
                    AgentTask.status == "running",
                    AgentTask.lease_expires_at < datetime.utcnow(),
                )
            )
        )
        for task in expired_tasks:
            if task.task_type == "write_chapter_proposal":
                task.status = "interrupted"
                proposal_id = (task.result or {}).get("proposal_id")
                if proposal_id is not None:
                    await session.execute(
                        update(WritingProposal)
                        .where(
                            WritingProposal.id == proposal_id,
                            WritingProposal.status == "generating",
                        )
                        .values(status="failed", error_code="lease_expired")
                    )
            elif task.task_type == "memory_extract":
                task.status = "pending"
            else:
                task.status = "interrupted"
                task.error = "任务 lease 已过期,请重新发起"
                task.finished_at = datetime.utcnow()
                await session.execute(
                    update(AgentStep)
                    .where(AgentStep.task_id == task.id, AgentStep.status == "running")
                    .values(
                        status="failed",
                        error="lease_expired",
                        finished_at=datetime.utcnow(),
                    )
                )
            task.lease_token = None
            task.lease_expires_at = None
        await session.commit()
        return len(expired_tasks)

这里不同任务类型有不同恢复策略。

write_chapter_proposal 生成提案失败后,要把提案也标记失败。

memory_extract 可以重新回到 pending,因为它基于已定稿版本提取记忆,重试风险较低。

其他任务标记为 interrupted,让用户明确知道任务中断了。

这就是任务状态机的价值。

同样是“worker 没了”,不同业务任务可以有不同恢复策略,而不是全部变成一个模糊的“失败”。

执行过程中,状态要不断落库

下面看真实写作任务执行。

我截取其中一段:

async def run_write_chapter_loop(task_id: int) -> None:
    current_step: AgentStep | None = None
    warnings: list[str] = []
    chapter_id = 0
    final_content = ""
    word_count = 0
    chapter_plan: dict[str, Any] = {}

    async with async_session() as db:
        task = await db.get(AgentTask, task_id)
        if not task:
            return

        try:
            await ensure_not_cancelled(db, task_id)
            plan_gate = await resolve_approved_plan_gate(
                db,
                novel_id=task.novel_id,
                chapter_number=task.target_chapter,
            )
            approved_plan = plan_gate.chapter_plan
            if plan_gate.outcome != "ready" or approved_plan is None:
                task.status = "pending_review"
                task.error = "缺少已批准的作品圣经或章节计划。"
                task.result = {
                    **(task.result or {}),
                    "review_required": "story_plan",
                    "chapter_number": task.target_chapter,
                    "error_code": plan_gate.error_code,
                }
                await db.commit()
                return

            context = await _load_context(db, task)
            current_step = await get_step(db, task_id, "load_context")
            if not is_step_done(current_step):
                await mark_step_running(db, current_step, "正在读取作品上下文")
                await mark_step_completed(
                    db,
                    current_step,
                    "上下文已加载",
                    context["summary"],
                )

            await ensure_not_cancelled(db, task_id)
            current_step = await get_step(db, task_id, "chapter_plan")
            chapter_plan = approved_story_plan_payload(approved_plan)
            await mark_step_running(db, current_step, "正在载入已批准章节计划")
            await mark_step_completed(
                db,
                current_step,
                "已使用批准章节计划",
                chapter_plan,
            )

            await ensure_not_cancelled(db, task_id)
            current_step = await get_step(db, task_id, "generate_draft")
            await mark_step_running(db, current_step, "正在生成章节草稿")
            controlled_draft = await ControlledProposalService(
                async_session
            ).generate_chapter_draft(
                db,
                task=task,
                context=context,
                chapter_plan=chapter_plan,
                word_target=word_target,
                skill_ids=skill_ids,
                system_prompt_builder=build_system_prompt,
                draft_generator=_generate_draft,
            )
            draft = controlled_draft.draft
            await mark_step_completed(
                db,
                current_step,
                "草稿已生成",
                {
                    "chars": len(draft),
                    "word_count": count_chinese_chars(draft),
                    "skill_ids": skill_ids,
                    "skills": context.get("skills", []),
                },
            )
        except AgentCancelled:
            task = await db.get(AgentTask, task_id)
            if task:
                task.status = "cancelled"
                task.finished_at = datetime.utcnow()
                await db.commit()
        except Exception as exc:
            await _mark_task_failed(db, task_id, current_step, exc)

这段代码有几个关键点。

第一,业务前置条件不满足时,不是直接 failed,而是 pending_review

比如缺少已批准章节计划,说明系统需要用户先审核计划,而不是模型出错。

第二,每个关键步骤前后都会更新 AgentStep

这让前端能展示进度,也让排查问题有抓手。

第三,长任务中间会多次调用 ensure_not_cancelled

取消不是粗暴 kill worker,而是在安全点检查取消信号。

第四,异常会落到当前步骤。

如果生成草稿失败,用户看到的是“生成章节草稿失败”,而不是一个模糊的“任务失败”。

取消:运行中任务不能直接删

取消任务也不能简单写成:

task.status = "cancelled"

因为任务可能正在运行。

如果 worker 正在调用模型,直接把状态改成 cancelled,worker 回来后可能继续写结果。

所以取消分两种情况:

pending 任务:还没开始,可以立即取消
running 任务:写入 cancel_requested_at,等 worker 在安全点停止

项目里的取消命令是这样:

async def cancel_agent_task_command(
    db: AsyncSession,
    task_id: int,
    *,
    operation_key: str | None = None,
    session_factory=None,
) -> AgentTask:
    """Cancel pending work now; signal running work for its worker safe point."""
    fingerprint = _fingerprint({"task_id": task_id})
    replay = await _replay_receipt(
        db,
        command="cancel",
        operation_key=operation_key,
        fingerprint=fingerprint,
    )
    if replay is not None:
        return replay

    task = await db.get(AgentTask, task_id)
    if task is None:
        raise AgentTaskCommandNotFound("task_not_found")

    now = datetime.utcnow()
    immediate = await db.execute(
        update(AgentTask)
        .where(
            AgentTask.id == task_id,
            AgentTask.status != "running",
            AgentTask.status.not_in(TERMINAL_STATUSES),
        )
        .values(
            status="cancelled",
            cancel_requested_at=now,
            finished_at=now,
            dedupe_key=None,
        )
    )
    if immediate.rowcount == 0:
        await db.execute(
            update(AgentTask)
            .where(AgentTask.id == task_id, AgentTask.status == "running")
            .values(cancel_requested_at=now)
        )

    await db.commit()
    cancelled = await db.get(AgentTask, task_id, populate_existing=True)
    if cancelled is None:
        raise AgentTaskCommandNotFound("task_not_found")
    return cancelled

对应的执行侧会检查:

async def is_cancelled(db: AsyncSession, task_id: int) -> bool:
    task = await db.get(AgentTask, task_id, populate_existing=True)
    return bool(task and (task.status == "cancelled" or task.cancel_requested_at is not None))


async def ensure_not_cancelled(db: AsyncSession, task_id: int) -> None:
    if await is_cancelled(db, task_id):
        raise AgentCancelled("任务已取消")

这个设计的关键是:取消是一个信号,不是一次粗暴覆盖。

只有 worker 到达安全点后,才真正停止后续步骤。

这对 Agent 长任务很重要。

尤其是任务中间可能已经生成提案、写入步骤输出、更新计划状态,不能随便中断到一半。

重试:不是重跑旧任务,而是创建 successor

失败后重试,也不要直接把旧任务改回 pending。

直接改旧任务有几个问题:

旧错误会被覆盖
旧步骤输出会混在新执行里
上下文快照来源说不清
幂等回放不好处理
用户看不到重试链路

所以当前设计里,重试会创建一个 successor task。

旧任务保留,新的任务通过 retry_of_task_id 指回旧任务。

async def retry_agent_task_command(
    db: AsyncSession,
    task_id: int,
    *,
    operation_key: str | None,
    restart: bool = False,
    expected_revision: int | None = None,
    session_factory=None,
) -> AgentTask:
    fingerprint = _fingerprint(
        {"task_id": task_id, "restart": restart, "expected_revision": expected_revision}
    )
    replay = await _replay_receipt(
        db,
        command="retry",
        operation_key=operation_key,
        fingerprint=fingerprint,
    )
    if replay is not None:
        return replay

    task = await db.get(AgentTask, task_id)
    if task is None:
        raise AgentTaskCommandNotFound("task_not_found")
    if task.status in ACTIVE_STATUSES:
        raise AgentTaskCommandConflict("task_still_active")
    if not operation_key:
        raise AgentTaskCommandValidationError("retry_requires_operation_key")

    retry_lock = await db.execute(
        update(AgentTask)
        .where(AgentTask.id == task.id, AgentTask.retry_operation_key.is_(None))
        .values(retry_operation_key=operation_key)
    )
    if retry_lock.rowcount != 1:
        await db.rollback()
        raise AgentTaskCommandConflict("task_already_retried")

    successor_input = {**(task.input or {}), "retry_of_task_id": task.id}
    successor = await build_agent_task(
        db,
        novel_id=task.novel_id,
        task_type=task.task_type,
        status="pending",
        target_chapter=task.target_chapter,
        auto_fix=task.auto_fix,
        input=successor_input,
        parent_task_id=task.parent_task_id,
        batch_index=task.batch_index,
        retry_of_task_id=task.id,
        retry_generation=task.retry_generation + 1,
        attempt=task.attempt + 1,
        dedupe_key=task.dedupe_key,
    )
    task.dedupe_key = None
    db.add(successor)
    await db.flush()

    task.result = {**(task.result or {}), "retry_task_id": successor.id}
    db.add(_receipt(
        task_id=successor.id,
        command="retry",
        operation_key=operation_key,
        fingerprint=fingerprint,
    ))
    await db.commit()
    await db.refresh(successor)
    await create_default_steps(db, successor.id)
    await db.refresh(successor)
    return successor

这样做以后,重试链路会非常清楚:

原任务 failed
    ↓
retry 命令创建新任务
    ↓
新任务 retry_of_task_id 指向原任务
    ↓
原任务 result 记录 retry_task_id

从审计角度看,这比“把原任务状态改回 pending”可靠很多。

中断恢复:pending_review 不是失败

Agent 长任务里还有一种状态很重要:pending_review

它不是失败。

它表示任务执行到某个需要人工确认的节点。

比如:

缺少已批准作品圣经
章节计划需要审核
连续性审核阻断
批量写作当前批次需要确认
提案生成完成,等待用户应用

这类情况不能简单标记 failed

因为系统并没有坏。

它只是需要人介入。

所以有 resume 命令:

async def resume_agent_task_command(
    db: AsyncSession,
    task_id: int,
    *,
    operation_key: str | None,
    expected_batch_index: int,
) -> AgentTask:
    """Atomically advance an approved and applied review batch back to pending."""
    if not operation_key:
        raise AgentTaskCommandValidationError("resume_requires_operation_key")
    fingerprint = _fingerprint({"task_id": task_id, "batch_index": expected_batch_index})
    replay = await _replay_receipt(
        db,
        command="resume",
        operation_key=operation_key,
        fingerprint=fingerprint,
    )
    if replay is not None:
        return replay

    task = await db.get(AgentTask, task_id)
    if task is None:
        raise AgentTaskCommandNotFound("task_not_found")

    review_required = (task.result or {}).get("review_required")
    requeue_same_batch = review_required in {"novel_bible", "story_plans"}
    await _ensure_current_batch_ready(
        db,
        task,
        require_proposals=not requeue_same_batch,
    )

    result = {**(task.result or {}), "resume": True}
    transition = await db.execute(
        update(AgentTask)
        .where(
            AgentTask.id == task_id,
            AgentTask.status == "pending_review",
            AgentTask.batch_index == expected_batch_index,
        )
        .values(
            status="pending",
            batch_index=expected_batch_index if requeue_same_batch else expected_batch_index + 1,
            result=result,
            error="",
        )
    )
    if transition.rowcount != 1:
        await db.rollback()
        raise AgentTaskCommandConflict("resume_state_conflict")

    db.add(_receipt(
        task_id=task_id,
        command="resume",
        operation_key=operation_key,
        fingerprint=fingerprint,
    ))
    await db.commit()
    resumed = await db.get(AgentTask, task_id, populate_existing=True)
    if resumed is None:
        raise AgentTaskCommandConflict("resume_task_missing")
    return resumed

这里的设计重点是:

只有 pending_review 可以 resume
resume 前要确认当前批次已经准备好
resume 要带 expected_batch_index,避免用户用旧页面继续旧批次
resume 也是幂等命令

这就是 Agent 和普通后台任务的区别之一。

普通任务通常只有成功和失败。

Agent 任务经常会有“跑到一半,需要人确认后继续”。

所以 pending_review 是 Agent 产品体验里很重要的状态。

API 层:任务和步骤要能被前端查询

状态机落库之后,前端需要能查询它。

所以 API 层提供了几个基础接口:

@router.get("/tasks", response_model=list[AgentTaskResponse])
async def list_agent_tasks(
    novel_id: int | None = None,
    task_type: str | None = None,
    active: bool = False,
    limit: int = Query(default=20, ge=1, le=50),
    db: AsyncSession = Depends(get_db),
):
    query = select(AgentTask)
    if novel_id is not None:
        query = query.where(AgentTask.novel_id == novel_id)
    if task_type:
        query = query.where(AgentTask.task_type == task_type)
    if active:
        query = query.where(AgentTask.status.in_(ACTIVE_STATUSES))
    query = query.order_by(AgentTask.created_at.desc()).limit(limit)
    result = await db.execute(query)
    return result.scalars().all()


@router.get("/tasks/{task_id}", response_model=AgentTaskResponse)
async def get_agent_task(task_id: int, db: AsyncSession = Depends(get_db)):
    task = await db.get(AgentTask, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="任务不存在")
    return task


@router.get("/tasks/{task_id}/steps", response_model=list[AgentStepResponse])
async def list_agent_steps(task_id: int, db: AsyncSession = Depends(get_db)):
    task = await db.get(AgentTask, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="任务不存在")
    result = await db.execute(
        select(AgentStep)
        .where(AgentStep.task_id == task_id)
        .order_by(AgentStep.sort_order)
    )
    return result.scalars().all()

以及几个命令接口:

@router.post("/tasks/{task_id}/cancel", response_model=AgentTaskResponse)
async def cancel_agent_task(...):
    return await cancel_agent_task_command(...)


@router.post("/tasks/{task_id}/retry", response_model=AgentTaskResponse)
async def retry_agent_task(...):
    return await retry_agent_task_command(...)


@router.post("/tasks/{task_id}/resume", response_model=AgentTaskResponse)
async def resume_agent_task(...):
    return await resume_agent_task_command(...)

这让前端不需要知道 worker 怎么执行。

前端只关心:

当前任务状态是什么?
步骤列表是什么?
能不能取消?
能不能继续?
能不能重试?

任务状态机把这些问题统一表达出来了。

前端任务面板:状态机直接变成用户体验

后端状态机如果前端不展示,用户还是只会看到一个黑盒。

所以前端也有对应类型:

export type AgentTaskStatus =
  | 'pending'
  | 'pending_review'
  | 'running'
  | 'completed'
  | 'failed'
  | 'cancelled'
  | 'interrupted';

export type AgentStepStatus =
  | 'pending'
  | 'running'
  | 'completed'
  | 'failed'
  | 'skipped';

export interface AgentTask {
  id: number;
  novel_id: number;
  task_type: AgentTaskType;
  status: AgentTaskStatus;
  target_chapter: number;
  input: Record<string, unknown>;
  result: Record<string, unknown>;
  error: string;
  created_at: string;
  started_at?: string | null;
  finished_at?: string | null;
}

export interface AgentStep {
  id: number;
  task_id: number;
  step_key: string;
  title: string;
  status: AgentStepStatus;
  message: string;
  input: Record<string, unknown>;
  output: Record<string, unknown>;
  error: string;
  sort_order: number;
  started_at?: string | null;
  finished_at?: string | null;
}

任务面板里会把状态映射成文案:

const statusText: Record<AgentTaskStatus, string> = {
  pending: '等待中',
  pending_review: '待审核',
  running: '执行中',
  completed: '已完成',
  failed: '失败',
  cancelled: '已取消',
  interrupted: '已中断',
};

function isActive(status?: AgentTaskStatus) {
  return status === 'pending' || status === 'running';
}

function canRetry(status?: AgentTaskStatus) {
  return (
    status === 'pending_review'
    || status === 'failed'
    || status === 'cancelled'
    || status === 'interrupted'
  );
}

进度条也不是随便估算。

而是基于步骤完成数:

const completed = c.agentSteps.filter(
  step => step.status === 'completed' || step.status === 'skipped'
).length;
const total = c.agentSteps.length || 7;
const progress = Math.round((completed / total) * 100);

步骤列表也是直接从 AgentStep 渲染:

{task && c.agentSteps.length > 0 && (
  <div className="mt-3 space-y-1.5">
    {c.agentSteps.map(step => (
      <div key={step.id} className="flex items-center gap-2 text-[12px]">
        <span>
          {step.status === 'completed'
            ? '✓'
            : step.status === 'running'
              ? '…'
              : step.status === 'failed'
                ? '!'
                : step.status === 'skipped'
                  ? '-'
                  : ''}
        </span>
        <span>{step.title}</span>
      </div>
    ))}
  </div>
)}

按钮状态也来自状态机:

{isActive(task?.status) && (
  <button onClick={c.cancelAgentTask}>
    取消
  </button>
)}

{canRetry(task?.status) && (
  <button onClick={c.retryAgentTask}>
    继续执行
  </button>
)}

这就是我说的:状态机不是后端自嗨。

它会直接决定用户能不能理解 Agent 正在做什么。

如果没有 AgentStep,前端只能显示“AI 正在处理”。

有了 AgentStep,前端可以显示:

正在读取作品上下文
已使用批准章节计划
正在生成章节草稿
待审核提案已创建
等待作者应用提案后再提取章节记忆

这个体验差别非常大。

这和工作流有什么区别,为什么不用工作流

这里可能会有人问:

你这不就是一个工作流吗?为什么不直接用工作流引擎?

这个问题很关键。

从表面看,确实很像。

都有任务,有步骤,有状态,有流转。

但我这里没有一开始引入通用工作流引擎,原因不是工作流不行,而是当前阶段要解决的核心问题不是“复杂流程编排”。

当前更核心的问题是:

Agent 执行过程如何被业务系统接住。

工作流更关注:

流程节点怎么定义
节点之间怎么跳转
谁来审批
超时怎么办
分支条件是什么
流程实例如何推进

AgentTask / AgentStep 更关注:

这次 Agent 调用了哪些上下文
模型输入是否冻结
执行到哪一步
哪一步失败了
结果是否能被审计
用户能不能取消
失败后能不能重试
提案是否还匹配原始版本
长期记忆是否来自确认内容

也就是说,工作流解决的是“流程怎么走”。

这里的状态机解决的是“Agent 怎么安全地执行”。

我暂时不用通用工作流引擎,主要有几个原因。

第一,当前流程还在快速演进。

Agent 写作、审核、修复、记忆提取这些边界还在调整。如果一开始就上通用工作流,很容易把还没稳定的业务流程固化到工作流定义里,后面每次改 Agent 能力都要改流程编排。

第二,Agent 步骤不是普通确定性节点。

比如 generate_draft 不是一个稳定的业务动作。

它依赖上下文快照、模型输出、工具结果、连续性审核。

它的结果可能是生成提案,也可能是进入 pending_review,还可能跳过后续步骤。

第三,当前更需要的是业务内聚,而不是通用抽象。

AgentTask 直接和小说系统里的章节、提案、上下文快照、版本号、记忆提取绑定。

它不是一个纯流程实例,而是 Agent 能力进入业务系统的执行外壳。

第四,工作流引擎不能替代业务边界。

即使用了工作流,也还是要解决:

上下文冻结
提案版本校验
章节 revision 冲突
模型输出审计
记忆来源校验
取消安全点
重试幂等

这些不是工作流引擎自动解决的,仍然要在业务层实现。

所以我对这件事的判断是:

先把 Agent 自己的任务状态机做扎实,再决定是否需要通用工作流。

当前系统真正需要优先打牢的是:

任务可持久化
步骤可观察
执行可取消
失败可重试
中断可恢复
输入可追溯
结果可审计
前端可展示

如果未来系统演进成多角色协作、多级审批、跨模块长流程,比如:

作者提交章节
编辑审核
AI 修复
主编确认
发布排期
数据回流

那时引入工作流引擎是合理的。

但在当前阶段,我更愿意先把 Agent 自己的执行状态机打牢。

因为这个层如果没有,工作流也接不住模型的不确定性。

设计 Agent 任务状态机的几个原则

结合这次项目,我现在会用这几个原则设计 Agent 长任务。

第一,任务状态和步骤状态分离。

任务回答“整体到哪了”,步骤回答“具体卡在哪”。

第二,状态要能表达业务语义。

pending_reviewfailed 不是一回事,skippedpending 也不是一回事。

第三,长任务执行必须有 lease。

不要相信内存里的 worker 状态,执行权要落库。

第四,取消要通过安全点。

运行中的任务不要粗暴覆盖状态,而是写入取消信号,让 worker 在步骤边界停下。

第五,重试要保留链路。

不要把旧任务抹掉,创建 successor,保留 retry_of_task_id

第六,任务创建和命令都要幂等。

Agent 任务通常很贵,重复执行的成本和风险都很高。

第七,前端展示要基于真实状态。

不要写死“AI 正在生成”,要展示步骤、错误和可操作按钮。

和前几篇的关系

到这里,这个系列的工程链路已经越来越完整了。

第一篇讲的是:为什么要把 Agent 从概念做进真实业务系统。

第二篇讲的是:Agent 执行前,要先有可信、可追溯的上下文工程。

第三篇讲的是:Agent 拿到上下文后,能调用哪些能力,哪些能力必须受业务边界约束。

这一篇讲的是:Agent 开始执行后,怎么记录状态、展示进度、处理取消、重试和中断恢复。

可以简单串起来:

上下文工程:Agent 知道什么
工具边界:Agent 能做什么
任务状态机:Agent 做到哪一步
长任务体验:用户如何理解、取消、恢复和继续

从架构师视角看,这一层很容易被低估。

很多团队做 Agent 时,会把注意力放在模型、prompt、工具调用上。

但真正进入产品后,用户感受到的不是你的 prompt 写得多漂亮,而是:

点了按钮有没有反馈
等了很久知不知道在干嘛
失败后能不能继续
取消后会不会留下脏数据
刷新页面任务还在不在
后台服务重启后结果会不会乱

这些都是任务状态机要解决的问题。

最后

这篇文章主要讲 AgentTaskAgentStep

我的结论很简单:

Agent 长任务不是一次 async 调用,而是一套可持久化的执行状态机。

AgentTask 记录任务整体。

AgentStep 记录过程细节。

lease 解决 worker 执行权。

cancel/retry/resume 解决长任务体验。

前端任务面板把这些状态展示给用户。

当这些工程底座搭起来之后,Agent 才不再是一个“点一下然后等结果”的黑盒。

它开始变成一个可以被用户理解、被系统恢复、被团队排查和持续演进的业务能力。

下一篇我会继续拆长任务体验:取消、重试、中断恢复和任务中心如何进一步产品化,以及为什么这些看起来“不 AI”的能力,反而决定了 Agent 能不能真正进入业务系统。