AI Agent 长任务实战:取消、重试、中断恢复,不是加几个按钮这么简单

本文是 AI Agent 工程实战系列第 5 篇。前几篇讲了上下文工程、工具边界和任务状态机,这一篇继续往产品化走:Agent 长任务跑起来之后,用户如何看懂、停住、续上、查到。

很多 AI Agent Demo 都有一个共同特点:跑起来很顺。

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

这个流程适合演示。

但一旦把 Agent 做进真实业务系统,问题会立刻变得具体:

text 复制代码
模型跑了 90 秒,页面要不要继续等?
用户点取消,是直接杀掉任务,还是等安全点停止?
生成提案失败了,点重试会不会重复生成?
服务重启后,running 任务怎么收尾?
任务从质检页、工作台、提案页发起,最后在哪里统一查看?

这些问题看起来不如 Prompt、Function Calling、模型路由那么"AI"。

但真正做过一轮之后,我反而觉得:

Agent 能不能进入真实业务,很大程度取决于这些不炫技的长任务体验。

上一篇我讲了 AgentTaskAgentStep,更偏后端状态机:任务怎么落库,步骤怎么记录,worker 怎么 claim,lease 怎么续租。

这一篇继续往前走一步,讲 状态机怎么变成用户可感知、可操作的产品体验

本文会围绕当前 AI Agent 小说创作系统,拆 5 个问题:

问题 这篇文章会回答什么
轮询 什么时候查任务状态,什么时候应该停?
取消 运行中的 Agent 任务怎么安全停止?
重试 如何避免一次失败造成重复执行?
中断恢复 服务重启后怎么处理 running 任务?
任务中心 多个入口发起的 Agent 任务在哪里统一管理?

如果你正在做 Agent 工程化,而不是只做一个聊天框,这些问题基本绕不开。

01. 先给结论

我现在对 Agent 长任务体验的设计原则是:

原则 对应工程动作
状态必须可见 前端持续展示任务状态和步骤进度
取消必须可控 running 任务只写取消信号,在安全点停止
重试必须幂等 每次重试使用稳定 operation key
恢复必须有策略 不同任务类型使用不同中断恢复策略
任务必须集中管理 工作台面板 + 独立任务中心统一承接

对应到系统里,就是几条链路:

链路 关键设计
前端轮询 只在任务活跃、页面可见时轮询
任务面板 把任务状态和可操作按钮展示给用户
取消命令 pending 立即取消,running 写取消信号
重试命令 保留旧任务,创建 successor
提案重试 必须带当前章节 revision,避免基于过期正文继续
中断恢复 不同任务有不同恢复策略
任务中心 统一查看写作、修复、质检、记忆任务

如果第 4 篇讲的是"Agent 怎么跑",这一篇讲的就是:

用户怎么理解 Agent 正在跑,以及系统怎么让用户安全地干预它。

02. 为什么这不是 UI 细节

很多 AI 功能一开始是这样的:

text 复制代码
按钮:生成
状态:生成中
结果:生成成功 / 生成失败

这对短任务没问题。

但 Agent 是长任务。

尤其是小说创作这个场景,一个 Agent 任务可能会做很多事:

text 复制代码
读取作品上下文
检查章节计划
推荐写作技能
生成草稿
连续性审核
创建提案
等待用户应用
定稿后提取记忆

用户等待的不是一次接口响应,而是一条执行链路。

如果页面只显示"AI 正在生成",用户会很快失去控制感。

他不知道:

text 复制代码
到底是在读上下文,还是在调模型?
是卡住了,还是还在运行?
取消会不会留下半成品?
失败后点继续,会不会重复生成?
刷新页面后能不能回来?

所以长任务体验不是 UI 细节。

它是 Agent 工程化的一部分。

03. 轮询不是无脑 setInterval

任务状态已经落库之后,前端最直接的办法是轮询。

但轮询也不能无脑做。

如果一直轮询:

text 复制代码
页面不可见也轮询
任务已结束也轮询
每个组件都自己轮询
任务状态变化后不刷新关联数据

最后会变成无意义请求和状态不同步。

当前工作台里有一个 useAgentTasks hook,专门管理任务列表、轮询、取消和重试。

tsx 复制代码
const ACTIVE_STATUSES: AgentTaskStatus[] = ['pending', 'running'];

function hasActiveTask(tasks: AgentTask[]) {
  return tasks.some((task) => ACTIVE_STATUSES.includes(task.status));
}

function usePageVisible() {
  const [isVisible, setIsVisible] = useState(
    () => document.visibilityState !== 'hidden',
  );
  useEffect(() => {
    const update = () => setIsVisible(document.visibilityState !== 'hidden');
    document.addEventListener('visibilitychange', update);
    return () => document.removeEventListener('visibilitychange', update);
  }, []);
  return isVisible;
}

这里先定义活跃任务:

text 复制代码
pending:任务已创建,等待 worker claim
running:任务正在执行

然后根据页面可见性决定是否轮询。

真正的查询逻辑是这样:

tsx 复制代码
export function useAgentTasks(
  novelId: number,
  {
    onAggregateInvalidate,
    onTaskStateChange,
    createRetryKey = newAgentTaskRetryKey,
  }: AgentTasksOptions = {},
): AgentTasksController {
  const queryClient = useQueryClient();
  const queryKey = workspaceQueryKeys.agentTasks(novelId);
  const isPageVisible = usePageVisible();

  const query = useQuery({
    queryKey,
    queryFn: async () => (
      await agentApi.listTasks({ novel_id: novelId, limit: 20 })
    ).data,
    enabled: novelId > 0,
    refetchInterval: (current) =>
      isPageVisible && hasActiveTask(current.state.data ?? [])
        ? 3000
        : false,
  });

  return {
    tasks: query.data ?? [],
    isLoading: query.isLoading,
    isError: query.isError,
    refresh: query.refetch,
    pollInterval: isPageVisible && hasActiveTask(query.data ?? []) ? 3000 : false,
    cancel: cancelMutation.mutateAsync,
    retry: (task, chapterRevision) =>
      retryMutation.mutateAsync({ task, chapterRevision }),
    isCancelling: cancelMutation.isPending,
    isRetrying: retryMutation.isPending,
  };
}

这里有几个体验上的细节。

第一,只在有活跃任务时轮询。

第二,页面不可见时停止轮询。

第三,任务状态变化时刷新聚合数据。

因为 Agent 任务完成后,可能会影响:

text 复制代码
提案列表
章节状态
质检结果
记忆提取状态
计划审核状态

所以 hook 里还记录了任务状态快照:

tsx 复制代码
const previousTaskSnapshot = useRef<string | undefined>(undefined);

useEffect(() => {
  const snapshot = (query.data ?? [])
    .map((task) => `${task.id}:${task.status}`)
    .join('|');
  if (
    previousTaskSnapshot.current !== undefined
    && previousTaskSnapshot.current !== snapshot
  ) {
    void onAggregateInvalidate?.({ includeRelatedKeys: false });
    onTaskStateChange?.(query.data ?? []);
  }
  previousTaskSnapshot.current = snapshot;
}, [novelId, onAggregateInvalidate, onTaskStateChange, query.data]);

这段逻辑解决的不是"显示任务列表"。

它解决的是:Agent 任务状态变化后,工作台其它面板要不要一起刷新。

这就是长任务体验里经常被忽略的地方。

04. 任务面板:把状态翻译成动作

用户不能只看状态。

他还需要操作任务。

比如:

text 复制代码
运行中:可以取消
失败:可以重试
中断:可以继续执行
待审核:可以进入审核或继续

工作台里的章节任务面板是这样做的:

tsx 复制代码
const STATUS_TEXT: 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'
  );
}

渲染时根据状态决定按钮:

tsx 复制代码
export function WorkspaceTaskPanel({
  tasks,
  selectedTaskId,
  cancel,
  retry,
  isCancelling,
  isRetrying,
}: {
  tasks: AgentTask[];
  selectedTaskId?: number;
  cancel: (taskId: number) => Promise<unknown>;
  retry: (task: AgentTask) => Promise<unknown>;
  isCancelling: boolean;
  isRetrying: boolean;
}) {
  return (
    <section className="workspace-tasks" aria-label="章节任务">
      <div className="workspace-section-heading">
        <span>章节任务</span>
        <small>{tasks.length}</small>
      </div>

      {tasks.length === 0 && (
        <p className="workspace-muted">当前章节暂无任务</p>
      )}

      {tasks.map((task) => (
        <div key={task.id}>
          <div
            className={`workspace-task-row${task.id === selectedTaskId ? ' is-target' : ''}`}
            aria-current={task.id === selectedTaskId ? 'true' : undefined}
          >
            <div>
              <strong>{TASK_TEXT[task.task_type]}</strong>
              <span>{STATUS_TEXT[task.status]}</span>
              {task.error && <p>{task.error}</p>}
            </div>

            {isActive(task.status) && (
              <button
                type="button"
                aria-label={`取消任务 #${task.id}`}
                disabled={isCancelling}
                onClick={() => void cancel(task.id)}
              >
                <XCircle size={14} />
              </button>
            )}

            {canRetry(task.status) && (
              <button
                type="button"
                aria-label={`重试任务 #${task.id}`}
                disabled={isRetrying}
                onClick={() => void retry(task)}
              >
                <RotateCcw size={14} />
              </button>
            )}
          </div>
        </div>
      ))}
    </section>
  );
}

这里的关键不是按钮样式。

而是状态到操作的映射:

任务状态 给用户的动作
pending / running 取消
pending_review / failed / cancelled / interrupted 继续执行
completed 不提供破坏性操作

这样用户看到的不是一堆技术状态,而是一组可理解的动作。

这就是把状态机产品化。

05. 取消:不是前端隐藏,而是后端命令

取消按钮不能只是前端把任务从列表里移除。

它必须落到后端命令。

前端调用:

tsx 复制代码
const cancelMutation = useMutation({
  mutationFn: (taskId: number) => agentApi.cancel(taskId),
  onSuccess: invalidate,
});

后端接口:

python 复制代码
@router.post("/tasks/{task_id}/cancel", response_model=AgentTaskResponse)
async def cancel_agent_task(
    task_id: int,
    idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
    db: AsyncSession = Depends(get_db),
    session_factory=Depends(get_session_factory),
):
    try:
        return await cancel_agent_task_command(
            db,
            task_id,
            operation_key=idempotency_key,
            session_factory=session_factory,
        )
    except (
        AgentTaskCommandConflict,
        AgentTaskCommandNotFound,
        AgentTaskCommandValidationError,
    ) as error:
        _raise_command_error(error)

真正取消逻辑在命令层。

python 复制代码
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

这段代码体现了一个重要区别:

text 复制代码
未运行任务:立即 cancelled
运行中任务:写 cancel_requested_at

也就是说,取消不是强杀。

它是一个安全停止协议。

worker 在步骤边界检查这个信号:

python 复制代码
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("任务已取消")

这才是用户点"取消"背后的工程含义:

不再继续后续步骤,但也不破坏已经安全落库的结果。

06. 重试:按钮背后要有幂等协议

重试按钮也容易做错。

很多系统会把"重试"做成:

text 复制代码
用户点一次
重新请求一次
失败了再点
再重新请求一次

这对 AI Agent 很危险。

因为一个重试请求可能真的创建了新任务,只是前端因为网络抖动没拿到响应。

如果用户再点一次,又创建一条新任务,就会出现重复执行。

所以前端重试时要生成并复用稳定的 operation key。

当前系统的实现是:

tsx 复制代码
export function newAgentTaskRetryKey() {
  return typeof crypto?.randomUUID === 'function'
    ? crypto.randomUUID()
    : `retry-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}

export function createAgentTaskRetrier(
  createRetryKey: () => string = newAgentTaskRetryKey,
) {
  const retryKeys = new Map<number, string>();

  return {
    async retry(
      task: AgentTask,
      resolveProposalRevision?: ProposalRevisionResolver,
    ) {
      let payload: { expected_revision?: number } = {};

      const operationKey = retryKeys.get(task.id) ?? createRetryKey();
      retryKeys.set(task.id, operationKey);
      const result = await agentApi.retry(task.id, payload, operationKey);
      retryKeys.delete(task.id);
      return result;
    },
  };
}

这段代码有一个很细的体验点:

场景 operation key 策略
同一次重试失败后再次点击 复用同一个 key
重试成功 删除 key
下一轮重试 生成新的 key

对应测试里也验证了这一点:

tsx 复制代码
it('retries an interrupted task with one stable operation key until it succeeds', async () => {
  const retry = vi.spyOn(agentApi, 'retry')
    .mockRejectedValueOnce({ response: { status: 422 } })
    .mockResolvedValue(response({ ...interruptedTask, status: 'pending' }));
  const createRetryKey = vi.fn()
    .mockReturnValueOnce('retry-10-key')
    .mockReturnValueOnce('retry-10-next-key');

  await expect(result.current.retry(interruptedTask))
    .rejects.toMatchObject({ response: { status: 422 } });
  await result.current.retry(interruptedTask);

  expect(retry).toHaveBeenNthCalledWith(1, 10, {}, 'retry-10-key');
  expect(retry).toHaveBeenNthCalledWith(2, 10, {}, 'retry-10-key');

  await result.current.retry(interruptedTask);
  expect(retry).toHaveBeenNthCalledWith(3, 10, {}, 'retry-10-next-key');
});

这就是为什么我一直强调:

Agent 长任务的按钮不是普通按钮,而是命令协议入口。

07. 提案重试:为什么必须带 revision

普通任务重试只需要 operation key。

但提案任务不一样。

因为提案是基于某个章节版本生成的。

如果用户在提案失败后又编辑了章节,重试就不能继续基于旧正文。

所以 write_chapter_proposal 重试时,前端必须读取当前章节 revision,并把它传给后端。

tsx 复制代码
export const PROPOSAL_CHAPTER_REQUIRED = '提案任务缺少安全关联章节,无法继续执行。';
export const PROPOSAL_REVISION_REQUIRED = '无法读取当前章节版本,未发送重试请求。';

export type ProposalRevisionResolver =
  (chapterId: number) => Promise<number | undefined>;

export function createAgentTaskRetrier(
  createRetryKey: () => string = newAgentTaskRetryKey,
) {
  const retryKeys = new Map<number, string>();

  return {
    async retry(
      task: AgentTask,
      resolveProposalRevision?: ProposalRevisionResolver,
    ) {
      let payload: { expected_revision?: number } = {};
      if (task.task_type === 'write_chapter_proposal') {
        if (!task.proposal_chapter_id) {
          throw new Error(PROPOSAL_CHAPTER_REQUIRED);
        }
        if (!resolveProposalRevision) {
          throw new Error(PROPOSAL_REVISION_REQUIRED);
        }
        const expectedRevision = await resolveProposalRevision(
          task.proposal_chapter_id,
        );
        if (
          typeof expectedRevision !== 'number'
          || !Number.isInteger(expectedRevision)
          || expectedRevision < 1
        ) {
          throw new Error(PROPOSAL_REVISION_REQUIRED);
        }
        payload = { expected_revision: expectedRevision };
      }

      const operationKey = retryKeys.get(task.id) ?? createRetryKey();
      retryKeys.set(task.id, operationKey);
      const result = await agentApi.retry(task.id, payload, operationKey);
      retryKeys.delete(task.id);
      return result;
    },
  };
}

任务中心里调用时,会先通过安全关联章节读取当前 revision:

tsx 复制代码
const { data } = await taskRetrier.current.retry(
  selectedTask,
  async (chapterId) => {
    try {
      return (await workspaceApi.getChapter(nid, chapterId)).revision;
    } catch {
      throw new Error(PROPOSAL_REVISION_REQUIRED);
    }
  },
);

测试也覆盖了这个协议:

tsx 复制代码
it('loads the safely linked proposal chapter revision before posting its CAS retry', async () => {
  workspaceApiMock.getChapter.mockResolvedValue({ id: 12, revision: 9 });
  apiMock.post.mockResolvedValue({
    data: task({
      task_type: 'write_chapter_proposal',
      proposal_chapter_id: 12,
      status: 'pending',
    }),
  });

  await user.click(await screen.findByRole('button', { name: '继续执行' }));

  expect(workspaceApiMock.getChapter).toHaveBeenCalledWith(7, 12);
  expect(apiMock.post).toHaveBeenCalledWith(
    '/agent/tasks/19/retry',
    { expected_revision: 9 },
    { headers: { 'Idempotency-Key': expect.any(String) } },
  );
});

这里的本质是 CAS,也就是 Compare-And-Set。

不是所有失败任务都能无脑重试。

如果任务和章节内容有关,必须确认当前版本仍然匹配。

这也是长任务体验和业务一致性结合的地方。

08. 中断恢复:别留下永远 running 的任务

第 4 篇讲过 lease。

这里再从体验角度看一次。

服务重启、worker 崩溃、模型调用超时,都可能让任务停在运行中。

如果系统没有恢复策略,用户看到的就是一个永远 running 的任务。

这对产品体验非常糟糕。

当前统一 worker 在启动时会做恢复:

python 复制代码
@classmethod
async def recover_startup(
    cls,
    session_factory: async_sessionmaker[AsyncSession],
) -> int:
    """Interrupt legacy active work and reconcile workspace leases."""
    async with session_factory() as session:
        now = datetime.utcnow()
        legacy = list(
            await session.scalars(
                select(AgentTask).where(
                    AgentTask.status == "running",
                    AgentTask.task_type.not_in(WORKSPACE_TASK_TYPES),
                )
            )
        )
        for task in legacy:
            task.status = "interrupted"
            task.error = "服务重启导致任务中断,请重新发起"
            task.finished_at = now
            task.lease_token = None
            task.lease_expires_at = None
            await session.execute(
                update(AgentStep)
                .where(
                    AgentStep.task_id == task.id,
                    AgentStep.status == "running",
                )
                .values(
                    status="failed",
                    error=task.error,
                    finished_at=now,
                )
            )
        await session.commit()
    dispatcher = AgentDispatcher(session_factory)
    return len(legacy) + await dispatcher.recover_expired()

这里不是简单地全部 failed。

它先把 legacy running 任务标记为 interrupted,并把正在运行的步骤标记失败。

然后再通过 dispatcher 处理过期 lease。

python 复制代码
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()
            task.lease_token = None
            task.lease_expires_at = None
        await session.commit()
        return len(expired_tasks)

注意这里的差异:

任务类型 恢复策略 原因
write_chapter_proposal 标记 interrupted,同时提案失败 生成内容中断,不能假装继续
memory_extract 回到 pending 基于已定稿版本,输入不可变,重试风险低
其它任务 标记 interrupted 让用户决定是否继续

为什么记忆提取可以回到 pending?

因为它基于已定稿版本提取记忆,输入来源是不可变的,重试风险较低。

提案生成就不同。

它可能涉及模型生成内容,用户需要知道这次生成已经中断,不能假装还在继续。

这就是"中断恢复"不是一句口号,而是每类任务要有不同策略。

09. 自动退避:哪些失败可以系统自己恢复

不是所有失败都应该让用户点按钮。

比如记忆提取这种任务,如果来源版本有效,只是提取服务短暂失败,可以自动延迟重试。

统一 worker 里有一段记忆任务退避重试:

python 复制代码
MEMORY_RETRY_BACKOFF_SECONDS = (5, 15, 45)

async def _run_memory(self, claim: TaskClaim) -> bool:
    completed = await run_memory_claim(
        self._session_factory,
        self._dispatcher,
        claim,
        memory_extractor=self._memory_extractor,
    )
    if completed:
        return True
    await self._schedule_memory_retry(claim)
    return False

调度 successor:

python 复制代码
async def _schedule_memory_retry(self, claim: TaskClaim) -> None:
    """Persist a delayed successor for transient immutable-memory failures."""
    async with self._session_factory() as session:
        task = await session.get(AgentTask, claim.task_id)
        if (
            task is None
            or task.task_type != "memory_extract"
            or task.status != "failed"
            or task.error == "memory_source_invalid"
        ):
            return
        retry_index = task.retry_generation
        if retry_index >= len(MEMORY_RETRY_BACKOFF_SECONDS):
            return
        delay = MEMORY_RETRY_BACKOFF_SECONDS[retry_index]
        dedupe_key = task.dedupe_key
        task.dedupe_key = None
        await session.flush()
        successor = await build_agent_task(
            session,
            novel_id=task.novel_id,
            task_type=task.task_type,
            status="pending",
            target_chapter=task.target_chapter,
            auto_fix=task.auto_fix,
            input=dict(task.input or {}),
            result={"retry_of_task_id": task.id},
            dedupe_key=dedupe_key,
            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,
            created_at=datetime.utcnow() + timedelta(seconds=delay),
        )
        session.add(successor)
        await session.flush()
        task.result = {**(task.result or {}), "retry_task_id": successor.id}
        await session.commit()

这里有一个边界:

失败类型 是否自动重试 处理方式
memory_source_invalid 停止任务,等待人工介入
transient failure 按 backoff 延迟创建 successor
超过重试次数 保留失败状态

这就是自动恢复和人工干预的分界。

系统能确定是临时失败,就自动恢复。

系统发现来源不可信,就停止,让人介入。

10. 任务中心:把散落的 Agent 任务收回来

工作台内的任务面板解决的是"当前章节"的任务。

但 Agent 系统一旦复杂起来,就会有很多任务:

text 复制代码
单章写作闭环
自动写作流水线
批量规则修复
质量闭环
自主驾驶
提案生成
记忆提取

这些任务不能散落在各个页面里。

所以前端有独立的 AgentTasks 页面。

入口路由:

tsx 复制代码
<Route
  path="/novel/:novelId/agents"
  element={
    <Suspense fallback={<PageFallback />}>
      <AgentTasks />
    </Suspense>
  }
/>

任务中心里定义了任务类型文案和状态文案:

tsx 复制代码
const TASK_TEXT: Record<AgentTaskType, string> = {
  write_chapter_loop: '单章写作闭环',
  auto_write_pipeline: '自动写作流水线',
  batch_fix: '批量规则修复',
  quality_loop: '质量闭环',
  autopilot: '自主驾驶',
  write_chapter_proposal: '提案生成',
  memory_extract: '记忆提取',
};

const FILTERS = [
  { key: 'all', label: '全部' },
  { key: 'active', label: '进行中' },
  { key: 'failed', label: '需处理' },
] as const;

加载任务时支持过滤:

tsx 复制代码
const loadTasks = useCallback(async () => {
  if (!nid) return;
  setError('');
  try {
    const params = filter === 'active'
      ? { novel_id: nid, active: true, limit: 20 }
      : { novel_id: nid, limit: 20 };
    const { data } = await agentApi.listTasks(params);
    const visible = filter === 'failed'
      ? data.filter(
          task => task.status === 'failed' || task.status === 'interrupted',
        )
      : data;
    setTasks(visible);
    setSelectedId((current) => {
      if (requestedTaskId && visible.some((task) => task.id === requestedTaskId)) {
        return requestedTaskId;
      }
      return current && visible.some(task => task.id === current)
        ? current
        : visible[0]?.id || null;
    });
  } catch (err) {
    setError(getApiErrorMessage(err, 'Agent 任务读取失败'));
  } finally {
    setLoading(false);
  }
}, [filter, nid, requestedTaskId]);

这里有一个很好用的细节:支持 deep link。

比如质检页创建了修复任务,可以直接跳到:

text 复制代码
/novel/7/agents?task=42

任务中心会自动选中这个任务。

这对复杂 Agent 系统很重要。

因为用户不一定从任务中心发起任务。

他可能从:

text 复制代码
质检问题列表
章节工作台
自动写作入口
提案审核面板
上下文审计页

跳到任务中心。

任务中心要能接住这些来源。

11. 任务详情:进度要来自真实 Step

任务中心不只是列表。

它还要展示 selected task 的步骤。

tsx 复制代码
const loadSteps = useCallback(async (taskId: number) => {
  try {
    const { data } = await agentApi.getSteps(taskId);
    setSteps(data);
  } catch (err) {
    setError(getApiErrorMessage(err, 'Agent 步骤读取失败'));
  }
}, []);

useEffect(() => {
  if (!selectedTask) {
    setSteps([]);
    return;
  }
  loadSteps(selectedTask.id);
}, [loadSteps, selectedTask]);

如果选中的任务仍然活跃,就持续刷新任务和步骤:

tsx 复制代码
useEffect(() => {
  if (!selectedTask || !isActive(selectedTask.status)) return;
  const timer = window.setInterval(() => {
    loadTasks();
    loadSteps(selectedTask.id);
  }, 3000);
  return () => window.clearInterval(timer);
}, [loadSteps, loadTasks, selectedTask]);

进度根据步骤完成数计算:

tsx 复制代码
function taskProgress(task: AgentTask, steps: AgentStep[]) {
  const total = steps.length;
  if (!total) return isActive(task.status) ? 8 : 0;
  const done = steps.filter(
    step => step.status === 'completed' || step.status === 'skipped',
  ).length;
  return Math.round((done / total) * 100);
}

这个进度不是模型随便报的。

它来自真实 AgentStep

所以用户看到的是系统状态,而不是安慰性的"预计进度"。

12. 错误协议:失败后要告诉前端下一步

长任务体验里还有一个容易忽略的问题:错误返回。

如果所有错误都只是:

json 复制代码
{ "detail": "failed" }

前端不知道应该让用户刷新、重试、跳转,还是停止操作。

所以系统里有一个稳定的 operation error 协议:

python 复制代码
def operation_error_detail(
    code: str,
    message: str | None = None,
    *,
    details: dict[str, Any] | None = None,
    refresh_scope: dict[str, int] | None = None,
    asset_deep_link: str | None = None,
    migration_url: str | None = None,
    workspace_url: str | None = None,
) -> dict[str, Any]:
    retry_class = _RETRY_CLASS_BY_CODE.get(code, "do_not_retry")
    return {
        "protocol_version": OPERATION_ERROR_PROTOCOL_VERSION,
        "code": code,
        "message": message or code,
        "details": details or {},
        "retry_class": retry_class,
        "refresh_scope": refresh_scope if retry_class == "refresh_and_confirm" else None,
        "asset_deep_link": asset_deep_link,
        "migration_url": migration_url,
        "workspace_url": workspace_url,
        "operation_key_reuse_policy": "do_not_replay",
    }

如果一个命令已经在执行中,也会返回可轮询目标:

python 复制代码
def operation_in_progress(
    *,
    kind: OperationKind,
    identifier: int,
    status: str,
    poll_target: str,
    include_legacy_receipt_id: bool = False,
) -> dict[str, Any]:
    payload = {
        "protocol_version": OPERATION_ERROR_PROTOCOL_VERSION,
        "kind": kind,
        "id": identifier,
        "status": status,
        "operation_key_reuse_policy": "reuse_original_key_for_polling",
        "poll_target": poll_target,
    }
    if include_legacy_receipt_id:
        payload["receipt_id"] = identifier
    return payload

这让前端可以知道:

text 复制代码
这个错误能不能重试
是否需要刷新后确认
是否应该跳转到任务中心
是否应该复用原 operation key 继续轮询

长任务系统最怕的不是失败。

最怕的是失败后系统和用户都不知道下一步该怎么办。

13. 可以直接拿走的检查表

如果你也在做 Agent 长任务,可以用下面这张表自查。

检查项 你需要确认的问题
任务状态是否可见 用户能否看到 pending / running / failed / interrupted / pending_review
步骤进度是否来自真实执行 进度条是根据 AgentStep 计算,还是前端假装估算?
取消是否是后端命令 running 任务是否通过 cancel_requested_at 等安全点停止?
重试是否幂等 同一次重试失败后再次点击,是否复用同一个 operation key?
重试是否校验业务版本 和章节、提案、正文有关的任务,是否带 expected_revision
中断是否有恢复策略 服务重启后,running 任务会变成 interruptedpending,还是永远卡住?
哪些失败可以自动恢复 transient failure 是否有 backoff?不可恢复错误是否会停止?
任务是否有统一入口 从质检、工作台、提案页发起的任务,能不能在任务中心查到?
错误是否可操作 后端是否告诉前端应该重试、刷新确认、跳转,还是停止?

这张表看起来很工程,但它决定的是用户敢不敢把任务交给 Agent。

14. 和第 4 篇的关系

第 4 篇讲的是状态机本身:

第 4 篇重点 说明
AgentTask 怎么设计 任务整体如何落库
AgentStep 怎么设计 步骤状态如何记录
worker 怎么 claim 如何抢占任务执行权
lease 怎么续租 如何证明 worker 还活着
running 怎么恢复 中断任务如何收尾

这一篇讲的是状态机如何变成产品体验:

本篇重点 说明
什么时候轮询 避免无意义轮询和状态不同步
状态怎么展示 把技术状态翻译成用户能理解的动作
哪些状态能取消 running 和 pending 的取消语义不同
哪些状态能继续 failed、interrupted、pending_review 都可能需要继续
重试怎么带幂等键 避免重复创建任务
提案重试怎么带 revision 避免基于过期正文继续生成
任务中心怎么统一接住任务 跨入口统一管理 Agent 任务
错误怎么告诉前端下一步动作 让失败变得可处理

从架构师视角看,这两层要一起设计。

如果只有后端状态机,用户看不到也操作不了。

如果只有前端按钮,后端没有命令协议和状态约束,就会留下脏数据和重复执行。

所以这两篇连起来看,Agent 长任务体验的本质是:

前端操作、后端命令、任务状态、业务一致性四者要闭环。

15. 总结

这篇文章主要讲 Agent 长任务体验。

我的结论很简单:

取消、重试、中断恢复和任务中心,不是 Agent 系统的附属功能,而是 Agent 进入真实业务的必要条件。

用户不是在等待一个模型返回。

用户是在和一个会执行、会停顿、会失败、会等待审核、会恢复的业务能力协作。

所以我们必须让它:

text 复制代码
看得见
停得住
续得上
查得到
错得明白
恢复得安全

当这些体验打通之后,Agent 才不再是一个"后台黑盒任务"。

它会变成一个用户敢点、团队敢查、系统敢恢复的业务执行者。

如果你正在做 Agent 系统,可以先不急着把工具越接越多。

先问自己一句:

这个 Agent 跑起来之后,用户能不能看懂、停住、续上、查到?

这个问题过了,Agent 才真正开始接近业务系统。

下一篇我会继续拆 AI 工程底座:模型路由、限流、错误处理和成本意识。因为当 Agent 任务真正跑起来之后,下一个问题就是:模型调用怎么稳定、怎么省钱、怎么兜底。

相关推荐
刘棕霆8 小时前
造数脚本越堆越乱:稳定的沉淀成引擎,变化的留在配置
aigc·agent·测试
网易云信8 小时前
网易智企亮相 2026 世界人工智能大会:一站式企业 AI 应用覆盖三大企业现场
人工智能·aigc·线下活动
JavaGuide8 小时前
Kimi K3 实战:全栈项目、Java 项目改造与 3A 游戏 Demo
后端·ai编程
谭光志8 小时前
深入浅出 RAG:用一个可运行的 Demo 讲透完整链路
前端·后端·ai编程
zhouhui0019 小时前
AI帮我写了个Spring Boot校验,线上漏掉了这组边界条件
java·spring boot·redis·ai编程
唐老板9 小时前
给 AI 套上缰绳:Harness Engineering 是什么
ai编程
我要割麦子9 小时前
从零到一手撸 Agent 系列 — 第 4 篇:工具的契约 — Tool 接口与注册表
agent·ai编程
未曾※放弃꿈9 小时前
Cursor 添加与切换背景图片教程(附带背景图)
ai编程
WaywardOne9 小时前
Flutter组件化方案(AI总结)
前端·flutter·ai编程