本文是 AI Agent 工程实战系列第 5 篇。前几篇讲了上下文工程、工具边界和任务状态机,这一篇继续往产品化走:Agent 长任务跑起来之后,用户如何看懂、停住、续上、查到。
很多 AI Agent Demo 都有一个共同特点:跑起来很顺。
用户点按钮,后端调模型,模型返回结果,页面展示。
这个流程适合演示。
但一旦把 Agent 做进真实业务系统,问题会立刻变得具体:
text
模型跑了 90 秒,页面要不要继续等?
用户点取消,是直接杀掉任务,还是等安全点停止?
生成提案失败了,点重试会不会重复生成?
服务重启后,running 任务怎么收尾?
任务从质检页、工作台、提案页发起,最后在哪里统一查看?
这些问题看起来不如 Prompt、Function Calling、模型路由那么"AI"。
但真正做过一轮之后,我反而觉得:
Agent 能不能进入真实业务,很大程度取决于这些不炫技的长任务体验。
上一篇我讲了 AgentTask 和 AgentStep,更偏后端状态机:任务怎么落库,步骤怎么记录,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 任务会变成 interrupted、pending,还是永远卡住? |
| 哪些失败可以自动恢复 | 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 任务真正跑起来之后,下一个问题就是:模型调用怎么稳定、怎么省钱、怎么兜底。