上一篇讲的是工具调用边界。
核心观点是:Agent 不是能调用越多工具越好,而是要知道哪些能力可以读,哪些能力只能生成建议,哪些写入必须经过业务命令、提案机制和版本校验。
工具边界拆清楚之后,接下来会遇到一个更工程的问题:
Agent 任务到底怎么跑?
很多 Agent Demo 会把执行过程写成一次函数调用:
text
用户点击按钮
后端 async 调模型
模型返回结果
页面展示
这个模型适合 Demo。
但一旦进入真实业务系统,就不够了。
因为真实的 Agent 任务不是一次模型调用。
它可能要经历:
text
加载上下文
检查章节计划
推荐写作技能
生成草稿
创建提案
等待用户审核
继续下一批章节
失败后重试
取消运行中任务
服务重启后恢复状态
前端持续展示进度
这些能力靠一个 async def run() 是撑不住的。
所以这一篇继续讲实战:我是怎么用 AgentTask 和 AgentStep 把 Agent 长任务做成可持久化、可观察、可恢复的状态机。
先说结论
在当前系统里,我把 Agent 执行状态拆成两层:
text
AgentTask:任务总账,记录这个 Agent 任务是什么、当前到哪、最终结果是什么
AgentStep:步骤明细,记录任务内部每一步的状态、输入、输出和错误
如果只用一句话概括:
AgentTask 解决"这个任务是否还活着",AgentStep 解决"这个任务具体卡在哪"。
这两个表配合后,系统才能回答这些问题:
text
任务是等待中、执行中、待审核、失败、取消,还是已中断?
任务执行到哪一步了?
当前步骤有没有输入输出?
任务失败后能不能继续?
服务重启后哪些任务需要标记中断?
页面刷新后用户还能不能看到进度?
同一个按钮重复点击会不会创建多条任务?
这也是我觉得 Agent 从 Demo 进入产品时必须补上的一层。
模型负责推理,状态机负责让推理过程进入业务系统。
为什么不能只靠一次 async 调用
最开始做 AI 功能时,很容易写出这种代码:
python
async def write_chapter(req):
context = await build_context(req.novel_id)
content = await call_model(context)
await save_chapter(content)
return content
这个写法最大的问题不是不能跑。
而是它跑起来之后,系统几乎回答不了任何工程问题。
比如:
text
模型调用 90 秒,前端怎么知道它还在跑?
用户点了取消,后端怎么安全停下?
生成草稿成功了,保存提案失败了,重试从哪里开始?
服务重启时,运行中的任务是什么状态?
任务失败了,错误发生在上下文、模型、保存还是审核?
用户刷新页面后,怎么恢复刚才的任务进度?
同一个请求因为网络重试打了两次,会不会重复执行?
这些问题不是 prompt 能解决的。
它们需要任务状态。
所以这次项目里,我没有把 Agent 设计成"一个接口返回最终结果",而是把它设计成"任务 + 步骤 + 命令 + worker"的结构。
大概是这样:
text
前端发起命令
↓
创建 AgentTask
↓
创建 AgentStep
↓
worker claim 任务
↓
每一步更新 AgentStep
↓
任务完成/失败/待审核/取消
↓
前端轮询任务和步骤
这就是本篇要讲的任务状态机。
第一层:AgentTask 是任务总账
先看数据模型。
AgentTask 不是简单的任务表。
它承载了几个关键职责:
text
任务类型:这是写章节、自动写作、批量修复、质检循环,还是记忆提取
任务状态: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
项目里的模型大概是这样:
python
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 让系统知道任务处在哪个阶段。
input 和 result 让任务可追溯。
context_snapshot_id 把任务和上下文快照绑定,避免重试时读取实时数据。
lease_token 和 heartbeat_at 让 worker 可以安全执行长任务。
request_key 和 dedupe_key 解决重复点击、网络重试和并发创建。
retry_of_task_id 和 retry_generation 让重试不是覆盖旧任务,而是形成链路。
这就是为什么我不建议只加一个 status 字段就结束。
Agent 任务的状态不是 UI 装饰,它是系统一致性的核心。
第二层:AgentStep 是过程明细
只有 AgentTask 还不够。
如果任务失败了,光知道它是 failed 没有太大帮助。
我们还需要知道:
text
失败发生在哪一步?
这一步开始了吗?
这一步输入是什么?
这一步输出了什么?
错误是什么?
前面哪些步骤已经完成?
重试时哪些步骤可以保留?
所以系统里有 AgentStep。
python
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)
任务状态和步骤状态的关系可以这样理解:
text
AgentTask.status = 任务整体状态
AgentStep.status = 某一步局部状态
比如写章节任务的步骤定义是:
python
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,不同任务可以有不同流程:
text
单章写作:上下文 -> 计划 -> 草稿 -> 提案 -> 报告
自动写作:规划章节 -> 逐章写作 -> 报告
质检循环:加载章节 -> 质检修复 -> 报告
自主驾驶:决策 -> 执行 -> 报告
这样做不会把所有 Agent 都塞进一个巨大的 run_agent() 里。
前端也能基于步骤定义展示任务进度。
创建任务时,先创建步骤
当用户发起一个 Agent 任务时,系统不是直接开始跑模型。
而是先创建 AgentTask,再为它创建默认步骤。
python
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 写下一章"之后,不应该只看到一个转圈。
他应该看到:
text
加载上下文:等待中
制定章节计划:等待中
生成章节草稿:等待中
保存草稿:等待中
生成报告:等待中
这就是"可观察"的第一步。
状态更新要收口成方法
有了步骤表之后,不要在业务代码里到处手写:
python
step.status = "running"
step.finished_at = ...
await db.commit()
否则一段时间后,状态字段会变得不一致。
所以项目里把步骤状态更新收口成几个方法:
python
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()
这几个方法看起来普通,但它们让状态语义稳定下来。
text
running:开始执行,记录 started_at
completed:成功完成,记录 output
failed:执行失败,记录 error
skipped:业务上跳过,但也算这个步骤已处理
skipped 很关键。
比如上一篇讲到,当前系统里写作结果先进入提案,不直接进入最终正文。
所以 review_chapter、fix_chapter、extract_memory 这些步骤会被跳过:
python
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。
项目里创建任务时,会先处理:
text
operation_key 幂等回放
任务类型校验
章节目标校验
上下文快照绑定
active dedupe 锁
默认步骤创建
核心代码如下:
python
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_token 和 lease_expires_at。
python
@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)
这里有一个很重要的点:
python
.where(AgentTask.id == task.id, AgentTask.status == "pending")
claim 不是先查再相信查询结果。
它要通过条件更新抢占任务。
如果两个 worker 同时查到同一条 pending 任务,只有一个 update 会成功。
这就是长任务执行里的第一道并发保护。
长任务要续租
Agent 任务经常会跑很久。
比如生成章节、质检多章、批量创建提案,都不是几百毫秒能结束的。
所以 worker claim 任务之后,还需要续租。
python
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 即使已经失去执行权,也可能继续更新任务。
这就像告诉系统:
text
不是任何人都能说"我还活着"
必须是当前持有 lease 的 worker 才能续租
后面完成任务、写入结果,也要带这个 token。
这对避免"旧 worker 写回旧结果"非常重要。
过期任务怎么恢复
有了 lease,就必须处理过期。
如果某个 worker 崩了,它不会再续租。
这时任务的 lease_expires_at 会过期。
系统可以把它恢复成可理解的状态。
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()
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 没了",不同业务任务可以有不同恢复策略,而不是全部变成一个模糊的"失败"。
执行过程中,状态要不断落库
下面看真实写作任务执行。
我截取其中一段:
python
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,而是在安全点检查取消信号。
第四,异常会落到当前步骤。
如果生成草稿失败,用户看到的是"生成章节草稿失败",而不是一个模糊的"任务失败"。
取消:运行中任务不能直接删
取消任务也不能简单写成:
python
task.status = "cancelled"
因为任务可能正在运行。
如果 worker 正在调用模型,直接把状态改成 cancelled,worker 回来后可能继续写结果。
所以取消分两种情况:
text
pending 任务:还没开始,可以立即取消
running 任务:写入 cancel_requested_at,等 worker 在安全点停止
项目里的取消命令是这样:
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
对应的执行侧会检查:
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("任务已取消")
这个设计的关键是:取消是一个信号,不是一次粗暴覆盖。
只有 worker 到达安全点后,才真正停止后续步骤。
这对 Agent 长任务很重要。
尤其是任务中间可能已经生成提案、写入步骤输出、更新计划状态,不能随便中断到一半。
重试:不是重跑旧任务,而是创建 successor
失败后重试,也不要直接把旧任务改回 pending。
直接改旧任务有几个问题:
text
旧错误会被覆盖
旧步骤输出会混在新执行里
上下文快照来源说不清
幂等回放不好处理
用户看不到重试链路
所以当前设计里,重试会创建一个 successor task。
旧任务保留,新的任务通过 retry_of_task_id 指回旧任务。
python
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
这样做以后,重试链路会非常清楚:
text
原任务 failed
↓
retry 命令创建新任务
↓
新任务 retry_of_task_id 指向原任务
↓
原任务 result 记录 retry_task_id
从审计角度看,这比"把原任务状态改回 pending"可靠很多。
中断恢复:pending_review 不是失败
Agent 长任务里还有一种状态很重要:pending_review。
它不是失败。
它表示任务执行到某个需要人工确认的节点。
比如:
text
缺少已批准作品圣经
章节计划需要审核
连续性审核阻断
批量写作当前批次需要确认
提案生成完成,等待用户应用
这类情况不能简单标记 failed。
因为系统并没有坏。
它只是需要人介入。
所以有 resume 命令:
python
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
这里的设计重点是:
text
只有 pending_review 可以 resume
resume 前要确认当前批次已经准备好
resume 要带 expected_batch_index,避免用户用旧页面继续旧批次
resume 也是幂等命令
这就是 Agent 和普通后台任务的区别之一。
普通任务通常只有成功和失败。
Agent 任务经常会有"跑到一半,需要人确认后继续"。
所以 pending_review 是 Agent 产品体验里很重要的状态。
API 层:任务和步骤要能被前端查询
状态机落库之后,前端需要能查询它。
所以 API 层提供了几个基础接口:
python
@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()
以及几个命令接口:
python
@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 怎么执行。
前端只关心:
text
当前任务状态是什么?
步骤列表是什么?
能不能取消?
能不能继续?
能不能重试?
任务状态机把这些问题统一表达出来了。
前端任务面板:状态机直接变成用户体验
后端状态机如果前端不展示,用户还是只会看到一个黑盒。
所以前端也有对应类型:
ts
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;
}
任务面板里会把状态映射成文案:
tsx
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'
);
}
进度条也不是随便估算。
而是基于步骤完成数:
tsx
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 渲染:
tsx
{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>
)}
按钮状态也来自状态机:
tsx
{isActive(task?.status) && (
<button onClick={c.cancelAgentTask}>
取消
</button>
)}
{canRetry(task?.status) && (
<button onClick={c.retryAgentTask}>
继续执行
</button>
)}
这就是我说的:状态机不是后端自嗨。
它会直接决定用户能不能理解 Agent 正在做什么。
如果没有 AgentStep,前端只能显示"AI 正在处理"。
有了 AgentStep,前端可以显示:
text
正在读取作品上下文
已使用批准章节计划
正在生成章节草稿
待审核提案已创建
等待作者应用提案后再提取章节记忆
这个体验差别非常大。
这和工作流有什么区别,为什么不用工作流
这里可能会有人问:
你这不就是一个工作流吗?为什么不直接用工作流引擎?
这个问题很关键。
从表面看,确实很像。
都有任务,有步骤,有状态,有流转。
但我这里没有一开始引入通用工作流引擎,原因不是工作流不行,而是当前阶段要解决的核心问题不是"复杂流程编排"。
当前更核心的问题是:
Agent 执行过程如何被业务系统接住。
工作流更关注:
text
流程节点怎么定义
节点之间怎么跳转
谁来审批
超时怎么办
分支条件是什么
流程实例如何推进
而 AgentTask / AgentStep 更关注:
text
这次 Agent 调用了哪些上下文
模型输入是否冻结
执行到哪一步
哪一步失败了
结果是否能被审计
用户能不能取消
失败后能不能重试
提案是否还匹配原始版本
长期记忆是否来自确认内容
也就是说,工作流解决的是"流程怎么走"。
这里的状态机解决的是"Agent 怎么安全地执行"。
我暂时不用通用工作流引擎,主要有几个原因。
第一,当前流程还在快速演进。
Agent 写作、审核、修复、记忆提取这些边界还在调整。如果一开始就上通用工作流,很容易把还没稳定的业务流程固化到工作流定义里,后面每次改 Agent 能力都要改流程编排。
第二,Agent 步骤不是普通确定性节点。
比如 generate_draft 不是一个稳定的业务动作。
它依赖上下文快照、模型输出、工具结果、连续性审核。
它的结果可能是生成提案,也可能是进入 pending_review,还可能跳过后续步骤。
第三,当前更需要的是业务内聚,而不是通用抽象。
AgentTask 直接和小说系统里的章节、提案、上下文快照、版本号、记忆提取绑定。
它不是一个纯流程实例,而是 Agent 能力进入业务系统的执行外壳。
第四,工作流引擎不能替代业务边界。
即使用了工作流,也还是要解决:
text
上下文冻结
提案版本校验
章节 revision 冲突
模型输出审计
记忆来源校验
取消安全点
重试幂等
这些不是工作流引擎自动解决的,仍然要在业务层实现。
所以我对这件事的判断是:
先把 Agent 自己的任务状态机做扎实,再决定是否需要通用工作流。
当前系统真正需要优先打牢的是:
text
任务可持久化
步骤可观察
执行可取消
失败可重试
中断可恢复
输入可追溯
结果可审计
前端可展示
如果未来系统演进成多角色协作、多级审批、跨模块长流程,比如:
text
作者提交章节
编辑审核
AI 修复
主编确认
发布排期
数据回流
那时引入工作流引擎是合理的。
但在当前阶段,我更愿意先把 Agent 自己的执行状态机打牢。
因为这个层如果没有,工作流也接不住模型的不确定性。
设计 Agent 任务状态机的几个原则
结合这次项目,我现在会用这几个原则设计 Agent 长任务。
第一,任务状态和步骤状态分离。
任务回答"整体到哪了",步骤回答"具体卡在哪"。
第二,状态要能表达业务语义。
pending_review 和 failed 不是一回事,skipped 和 pending 也不是一回事。
第三,长任务执行必须有 lease。
不要相信内存里的 worker 状态,执行权要落库。
第四,取消要通过安全点。
运行中的任务不要粗暴覆盖状态,而是写入取消信号,让 worker 在步骤边界停下。
第五,重试要保留链路。
不要把旧任务抹掉,创建 successor,保留 retry_of_task_id。
第六,任务创建和命令都要幂等。
Agent 任务通常很贵,重复执行的成本和风险都很高。
第七,前端展示要基于真实状态。
不要写死"AI 正在生成",要展示步骤、错误和可操作按钮。
和前几篇的关系
到这里,这个系列的工程链路已经越来越完整了。
第一篇讲的是:为什么要把 Agent 从概念做进真实业务系统。
第二篇讲的是:Agent 执行前,要先有可信、可追溯的上下文工程。
第三篇讲的是:Agent 拿到上下文后,能调用哪些能力,哪些能力必须受业务边界约束。
这一篇讲的是:Agent 开始执行后,怎么记录状态、展示进度、处理取消、重试和中断恢复。
可以简单串起来:
text
上下文工程:Agent 知道什么
工具边界:Agent 能做什么
任务状态机:Agent 做到哪一步
长任务体验:用户如何理解、取消、恢复和继续
从架构师视角看,这一层很容易被低估。
很多团队做 Agent 时,会把注意力放在模型、prompt、工具调用上。
但真正进入产品后,用户感受到的不是你的 prompt 写得多漂亮,而是:
text
点了按钮有没有反馈
等了很久知不知道在干嘛
失败后能不能继续
取消后会不会留下脏数据
刷新页面任务还在不在
后台服务重启后结果会不会乱
这些都是任务状态机要解决的问题。
最后
这篇文章主要讲 AgentTask 和 AgentStep。
我的结论很简单:
Agent 长任务不是一次 async 调用,而是一套可持久化的执行状态机。
AgentTask 记录任务整体。
AgentStep 记录过程细节。
lease 解决 worker 执行权。
cancel/retry/resume 解决长任务体验。
前端任务面板把这些状态展示给用户。
当这些工程底座搭起来之后,Agent 才不再是一个"点一下然后等结果"的黑盒。
它开始变成一个可以被用户理解、被系统恢复、被团队排查和持续演进的业务能力。
下一篇我会继续拆长任务体验:取消、重试、中断恢复和任务中心如何进一步产品化,以及为什么这些看起来"不 AI"的能力,反而决定了 Agent 能不能真正进入业务系统。