海域重启礼包码汇总 海域重启最新可用兑换码分享
2026-07-21
2026-07-22 0
很多 AI Agent Demo 都有一个共同特点:跑起来很顺。

用户点按钮,后端调模型,模型返回结果,页面展示。
这个流程适合演示。
但一旦把 Agent 做进真实业务系统,问题会立刻变得具体:
模型跑了 90 秒,页面要不要继续等?用户点取消,是直接杀掉任务,还是等安全点停止?生成提案失败了,点重试会不会重复生成?服务重启后,running 任务怎么收尾?任务从质检页、工作台、提案页发起,最后在哪里统一查看?
这些问题看起来不如 Prompt、Function Calling、模型路由那么“AI”。
但真正做过一轮之后,我反而觉得:
上一篇我讲了 AgentTask 和 AgentStep,更偏后端状态机:任务怎么落库,步骤怎么记录,worker 怎么 claim,lease 怎么续租。
这一篇继续往前走一步,讲 状态机怎么变成用户可感知、可操作的产品体验。
本文会围绕当前 AI Agent 小说创作系统,拆 5 个问题:
| 问题 | 这篇文章会回答什么 |
|---|---|
| 轮询 | 什么时候查任务状态,什么时候应该停? |
| 取消 | 运行中的 Agent 任务怎么安全停止? |
| 重试 | 如何避免一次失败造成重复执行? |
| 中断恢复 | 服务重启后怎么处理 running 任务? |
| 任务中心 | 多个入口发起的 Agent 任务在哪里统一管理? |
如果你正在做 Agent 工程化,而不是只做一个聊天框,这些问题基本绕不开。
我现在对 Agent 长任务体验的设计原则是:
| 原则 | 对应工程动作 |
|---|---|
| 状态必须可见 | 前端持续展示任务状态和步骤进度 |
| 取消必须可控 | running 任务只写取消信号,在安全点停止 |
| 重试必须幂等 | 每次重试使用稳定 operation key |
| 恢复必须有策略 | 不同任务类型使用不同中断恢复策略 |
| 任务必须集中管理 | 工作台面板 + 独立任务中心统一承接 |
对应到系统里,就是几条链路:
| 链路 | 关键设计 |
|---|---|
| 前端轮询 | 只在任务活跃、页面可见时轮询 |
| 任务面板 | 把任务状态和可操作按钮展示给用户 |
| 取消命令 | pending 立即取消,running 写取消信号 |
| 重试命令 | 保留旧任务,创建 successor |
| 提案重试 | 必须带当前章节 revision,避免基于过期正文继续 |
| 中断恢复 | 不同任务有不同恢复策略 |
| 任务中心 | 统一查看写作、修复、质检、记忆任务 |
如果第 4 篇讲的是“Agent 怎么跑”,这一篇讲的就是:
很多 AI 功能一开始是这样的:
按钮:生成状态:生成中结果:生成成功 / 生成失败
这对短任务没问题。
但 Agent 是长任务。
尤其是小说创作这个场景,一个 Agent 任务可能会做很多事:
读取作品上下文检查章节计划推荐写作技能生成草稿连续性审核创建提案等待用户应用定稿后提取记忆
用户等待的不是一次接口响应,而是一条执行链路。
如果页面只显示“AI 正在生成”,用户会很快失去控制感。
他不知道:
到底是在读上下文,还是在调模型?是卡住了,还是还在运行?取消会不会留下半成品?失败后点继续,会不会重复生成?刷新页面后能不能回来?
所以长任务体验不是 UI 细节。
它是 Agent 工程化的一部分。
任务状态已经落库之后,前端最直接的办法是轮询。
但轮询也不能无脑做。
如果一直轮询:
页面不可见也轮询任务已结束也轮询每个组件都自己轮询任务状态变化后不刷新关联数据
最后会变成无意义请求和状态不同步。
当前工作台里有一个 useAgentTasks hook,专门管理任务列表、轮询、取消和重试。
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;}
这里先定义活跃任务:
pending:任务已创建,等待 worker claimrunning:任务正在执行
然后根据页面可见性决定是否轮询。
真正的查询逻辑是这样:
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 任务完成后,可能会影响:
提案列表章节状态质检结果记忆提取状态计划审核状态
所以 hook 里还记录了任务状态快照:
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 任务状态变化后,工作台其它面板要不要一起刷新。
这就是长任务体验里经常被忽略的地方。
用户不能只看状态。
他还需要操作任务。
比如:
运行中:可以取消失败:可以重试中断:可以继续执行待审核:可以进入审核或继续
工作台里的章节任务面板是这样做的:
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');}
渲染时根据状态决定按钮:
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}><divclassName={`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) && (<buttontype="button"aria-label={`取消任务 #${task.id}`}disabled={isCancelling}onClick={() => void cancel(task.id)}><XCircle size={14} />button>)}{canRetry(task.status) && (<buttontype="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 | 不提供破坏性操作 |
这样用户看到的不是一堆技术状态,而是一组可理解的动作。
这就是把状态机产品化。
取消按钮不能只是前端把任务从列表里移除。
它必须落到后端命令。
前端调用:
const cancelMutation = useMutation({mutationFn: (taskId: number) => agentApi.cancel(taskId),onSuccess: invalidate,});
后端接口:
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)
真正取消逻辑在命令层。
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 replaytask = 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
这段代码体现了一个重要区别:
未运行任务:立即 cancelled运行中任务:写 cancel_requested_at
也就是说,取消不是强杀。
它是一个安全停止协议。
worker 在步骤边界检查这个信号:
async def is_cancelled(db: AsyncSession, task_id: int) -> bool:task = await db.get(AgentTask, task_id, populate_existing=True)return bool(taskand (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("任务已取消")
这才是用户点“取消”背后的工程含义:
重试按钮也容易做错。
很多系统会把“重试”做成:
用户点一次重新请求一次失败了再点再重新请求一次
这对 AI Agent 很危险。
因为一个重试请求可能真的创建了新任务,只是前端因为网络抖动没拿到响应。
如果用户再点一次,又创建一条新任务,就会出现重复执行。
所以前端重试时要生成并复用稳定的 operation key。
当前系统的实现是:
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 |
对应测试里也验证了这一点:
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');});
这就是为什么我一直强调:
普通任务重试只需要 operation key。
但提案任务不一样。
因为提案是基于某个章节版本生成的。
如果用户在提案失败后又编辑了章节,重试就不能继续基于旧正文。
所以 write_chapter_proposal 重试时,前端必须读取当前章节 revision,并把它传给后端。
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:
const { data } = await taskRetrier.current.retry(selectedTask,async (chapterId) => {try {return (await workspaceApi.getChapter(nid, chapterId)).revision;} catch {throw new Error(PROPOSAL_REVISION_REQUIRED);}},);
测试也覆盖了这个协议:
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。
不是所有失败任务都能无脑重试。
如果任务和章节内容有关,必须确认当前版本仍然匹配。
这也是长任务体验和业务一致性结合的地方。
第 4 篇讲过 lease。
这里再从体验角度看一次。
服务重启、worker 崩溃、模型调用超时,都可能让任务停在运行中。
如果系统没有恢复策略,用户看到的就是一个永远 running 的任务。
这对产品体验非常糟糕。
当前统一 worker 在启动时会做恢复:
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 = nowtask.lease_token = Nonetask.lease_expires_at = Noneawait 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。
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 = Nonetask.lease_expires_at = Noneawait session.commit()return len(expired_tasks)
注意这里的差异:
| 任务类型 | 恢复策略 | 原因 |
|---|---|---|
write_chapter_proposal | 标记 interrupted,同时提案失败 | 生成内容中断,不能假装继续 |
memory_extract | 回到 pending | 基于已定稿版本,输入不可变,重试风险低 |
| 其它任务 | 标记 interrupted | 让用户决定是否继续 |
为什么记忆提取可以回到 pending?
因为它基于已定稿版本提取记忆,输入来源是不可变的,重试风险较低。
提案生成就不同。
它可能涉及模型生成内容,用户需要知道这次生成已经中断,不能假装还在继续。
这就是“中断恢复”不是一句口号,而是每类任务要有不同策略。
不是所有失败都应该让用户点按钮。
比如记忆提取这种任务,如果来源版本有效,只是提取服务短暂失败,可以自动延迟重试。
统一 worker 里有一段记忆任务退避重试:
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 Trueawait self._schedule_memory_retry(claim)return False
调度 successor:
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 Noneor task.task_type != "memory_extract"or task.status != "failed"or task.error == "memory_source_invalid"):returnretry_index = task.retry_generationif retry_index >= len(MEMORY_RETRY_BACKOFF_SECONDS):returndelay = MEMORY_RETRY_BACKOFF_SECONDS[retry_index]dedupe_key = task.dedupe_keytask.dedupe_key = Noneawait 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 |
| 超过重试次数 | 否 | 保留失败状态 |
这就是自动恢复和人工干预的分界。
系统能确定是临时失败,就自动恢复。
系统发现来源不可信,就停止,让人介入。
工作台内的任务面板解决的是“当前章节”的任务。
但 Agent 系统一旦复杂起来,就会有很多任务:
单章写作闭环自动写作流水线批量规则修复质量闭环自主驾驶提案生成记忆提取
这些任务不能散落在各个页面里。
所以前端有独立的 AgentTasks 页面。
入口路由:
<Routepath="/novel/:novelId/agents"element={<Suspense fallback={<PageFallback />}><AgentTasks />Suspense>}/>
任务中心里定义了任务类型文案和状态文案:
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;
加载任务时支持过滤:
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。
比如质检页创建了修复任务,可以直接跳到:
/novel/7/agents?task=42
任务中心会自动选中这个任务。
这对复杂 Agent 系统很重要。
因为用户不一定从任务中心发起任务。
他可能从:
质检问题列表章节工作台自动写作入口提案审核面板上下文审计页
跳到任务中心。
任务中心要能接住这些来源。
任务中心不只是列表。
它还要展示 selected task 的步骤。
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]);
如果选中的任务仍然活跃,就持续刷新任务和步骤:
useEffect(() => {if (!selectedTask || !isActive(selectedTask.status)) return;const timer = window.setInterval(() => {loadTasks();loadSteps(selectedTask.id);}, 3000);return () => window.clearInterval(timer);}, [loadSteps, loadTasks, selectedTask]);
进度根据步骤完成数计算:
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。
所以用户看到的是系统状态,而不是安慰性的“预计进度”。
长任务体验里还有一个容易忽略的问题:错误返回。
如果所有错误都只是:
{ "detail": "failed" }
前端不知道应该让用户刷新、重试、跳转,还是停止操作。
所以系统里有一个稳定的 operation error 协议:
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",}
如果一个命令已经在执行中,也会返回可轮询目标:
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"] = identifierreturn payload
这让前端可以知道:
这个错误能不能重试是否需要刷新后确认是否应该跳转到任务中心是否应该复用原 operation key 继续轮询
长任务系统最怕的不是失败。
最怕的是失败后系统和用户都不知道下一步该怎么办。
如果你也在做 Agent 长任务,可以用下面这张表自查。
| 检查项 | 你需要确认的问题 |
|---|---|
| 任务状态是否可见 | 用户能否看到 pending / running / failed / interrupted / pending_review? |
| 步骤进度是否来自真实执行 | 进度条是根据 AgentStep 计算,还是前端假装估算? |
| 取消是否是后端命令 | running 任务是否通过 cancel_requested_at 等安全点停止? |
| 重试是否幂等 | 同一次重试失败后再次点击,是否复用同一个 operation key? |
| 重试是否校验业务版本 | 和章节、提案、正文有关的任务,是否带 expected_revision? |
| 中断是否有恢复策略 | 服务重启后,running 任务会变成 interrupted、pending,还是永远卡住? |
| 哪些失败可以自动恢复 | transient failure 是否有 backoff?不可恢复错误是否会停止? |
| 任务是否有统一入口 | 从质检、工作台、提案页发起的任务,能不能在任务中心查到? |
| 错误是否可操作 | 后端是否告诉前端应该重试、刷新确认、跳转,还是停止? |
这张表看起来很工程,但它决定的是用户敢不敢把任务交给 Agent。
第 4 篇讲的是状态机本身:
| 第 4 篇重点 | 说明 |
|---|---|
AgentTask 怎么设计 | 任务整体如何落库 |
AgentStep 怎么设计 | 步骤状态如何记录 |
| worker 怎么 claim | 如何抢占任务执行权 |
| lease 怎么续租 | 如何证明 worker 还活着 |
| running 怎么恢复 | 中断任务如何收尾 |
这一篇讲的是状态机如何变成产品体验:
| 本篇重点 | 说明 |
|---|---|
| 什么时候轮询 | 避免无意义轮询和状态不同步 |
| 状态怎么展示 | 把技术状态翻译成用户能理解的动作 |
| 哪些状态能取消 | running 和 pending 的取消语义不同 |
| 哪些状态能继续 | failed、interrupted、pending_review 都可能需要继续 |
| 重试怎么带幂等键 | 避免重复创建任务 |
| 提案重试怎么带 revision | 避免基于过期正文继续生成 |
| 任务中心怎么统一接住任务 | 跨入口统一管理 Agent 任务 |
| 错误怎么告诉前端下一步动作 | 让失败变得可处理 |
从架构师视角看,这两层要一起设计。
如果只有后端状态机,用户看不到也操作不了。
如果只有前端按钮,后端没有命令协议和状态约束,就会留下脏数据和重复执行。
所以这两篇连起来看,Agent 长任务体验的本质是:
这篇文章主要讲 Agent 长任务体验。
我的结论很简单:
用户不是在等待一个模型返回。
用户是在和一个会执行、会停顿、会失败、会等待审核、会恢复的业务能力协作。
所以我们必须让它:
看得见停得住续得上查得到错得明白恢复得安全
当这些体验打通之后,Agent 才不再是一个“后台黑盒任务”。
它会变成一个用户敢点、团队敢查、系统敢恢复的业务执行者。
如果你正在做 Agent 系统,可以先不急着把工具越接越多。
先问自己一句:
这个问题过了,Agent 才真正开始接近业务系统。
下一篇我会继续拆 AI 工程底座:模型路由、限流、错误处理和成本意识。因为当 Agent 任务真正跑起来之后,下一个问题就是:模型调用怎么稳定、怎么省钱、怎么兜底。