上一篇文章讲的是上下文工程。
核心观点是:Agent 真正开始执行之前,必须先知道自己基于哪些事实做判断。作品、角色、世界观、大纲、章节历史、风格锁、写作规则,这些都不能靠前端临时拼 prompt。
这一篇继续按原来的系列规划往下走,讲 工具调用设计。
这一篇我会多贴一些完整实现。
因为工程实战型文章如果只写原则,读者很容易觉得“听懂了,但回到项目还是不知道怎么拆”。
所以后面每一段我都会尽量用当前项目里的真实代码说明边界。
但这里的“工具调用”,不是简单讲 Function Calling。
我更想讲的是:在真实业务系统里,哪些能力应该暴露给 Agent,哪些能力不应该直接暴露,写作、审核、修复、记忆提取这些能力之间的边界应该怎么拆。
因为做 Agent 系统时,很容易掉进一个坑:
把所有后端接口都包装成工具,然后交给模型自己调。
这听起来很 Agent。
但真正落到业务系统里,风险很大。
模型如果只是读错上下文,最多生成内容质量差。
但模型如果能直接写章节、改大纲、改人物状态、更新记忆,就可能污染核心数据。
所以这一篇的重点不是“怎么让模型调用更多工具”,而是:
怎么让 Agent 在明确边界内调用能力。
先说结论
在这个项目里,我把 Agent 能力拆成四类:
写作:生成内容,但不等于直接落库成最终正文
审核:发现问题,但不等于自动修改正文
修复:生成修订建议,但必须受来源版本约束
记忆提取:沉淀事实,但只能基于已确认内容
这四类能力看起来都可以叫“工具”。
但它们的风险等级完全不同。
写作生成的是候选内容。
审核生成的是判断结果。
修复生成的是修改建议。
记忆提取生成的是长期状态。
所以它们不能用同一种调用方式处理。
我的设计原则是:
读能力可以先开放
生成能力必须可追踪
写入能力必须走状态机
长期记忆必须基于确认事实
也就是说,Agent 可以参与创作流程,但不能绕过业务边界。
为什么不能把所有能力都做成工具
很多 Agent Demo 里,工具调用大概是这样:
模型决定调用工具
后端执行工具
工具返回结果
模型继续推理
这个流程没问题。
但它只解决了“模型怎么调用函数”的问题,没有解决“这个函数能不能被模型调用”的问题。
真实系统里,工具设计要先回答这些问题:
这个工具是读还是写?
会不会修改用户数据?
能不能重复执行?
失败后能不能重试?
结果是否需要人工确认?
执行结果是否会影响长期状态?
调用过程是否需要被审计?
如果不区分这些,Agent 很容易变成一个权限过大的自动化脚本。
在小说创作系统里,这个问题尤其明显。
比如下面这些动作,风险完全不同:
读取上一章结尾
搜索写作技能
生成章节草稿
审核章节问题
根据问题修复正文
保存章节正文
应用提案
提取长期记忆
修改人物状态
修改世界规则
如果都叫“工具”,系统边界会非常混乱。
所以我在项目里先做了拆分:读上下文是一类,生成内容是一类,修改正文是一类,沉淀记忆又是一类。
第一类:写作工具,只负责生成候选内容
先看写作。
最早的 AI 写作系统通常是这样:
输入 prompt
生成正文
保存章节
这个流程简单,但风险也大。
因为模型生成的内容不一定符合计划,不一定符合人物状态,也不一定符合世界规则。
所以在当前系统里,写作能力已经逐步从“直接生成并覆盖正文”演进成“基于上下文快照生成候选内容”。
async def _generate_draft(
task: AgentTask,
context: dict[str, Any],
system_prompt: str,
chapter_plan: dict[str, Any],
) -> str:
del chapter_plan
messages = context.get("frozen_messages")
if (
not isinstance(messages, list)
or len(messages) < 2
or not all(isinstance(message, dict) for message in messages[:2])
):
raise RuntimeError("context_snapshot_required")
frozen_system = str(messages[0].get("content") or system_prompt)
frozen_user = str(messages[1].get("content") or "")
return (
await chat_text(
frozen_system,
frozen_user,
temperature=0.85,
max_tokens=6000,
timeout=300,
task="writing",
novel_id=task.novel_id,
)
).strip()
这里有一个很重要的变化:它不再随手用实时数据库拼 prompt。
它优先使用 frozen_messages。
也就是说,模型输入在任务执行前已经被渲染和冻结。
这样做的意义是:
同一个任务重试时,输入可追溯
模型到底看到了什么可以审计
不会因为执行中途数据变化导致输入漂移
后续生成结果可以和上下文快照绑定
写作工具的边界因此变成:
根据已冻结上下文生成候选正文,不负责决定这段正文是否直接进入作品。
这和“生成后直接保存”是两个完全不同的系统设计。
第二类:审核工具,只负责判断问题
写作之后,下一步是审核。
审核工具的边界也要明确。
它不是修复器,也不是保存器。
它只负责回答:
这章质量怎么样?
有哪些问题?
问题严重程度如何?
建议怎么改?
本章有什么亮点?
当前 _review_chapter 大概是这样:
async def _review_chapter(
task: AgentTask,
context: dict[str, Any],
content: str,
word_count: int,
) -> dict[str, Any]:
novel: Novel = context["novel"]
prompt = f"""你是专业网文编辑,审核《{novel.title}》第{task.target_chapter}章。
章节字数:{word_count}
章节正文:
{content[:7000]}
请输出严格 JSON:
{{
"summary": "一句话总结本章质量",
"issues": [
{{
"type": "pacing/logic/character_voice/hook/prose/conflict",
"severity": "critical/warning/info",
"problem": "问题描述",
"suggestion": "修改建议"
}}
],
"highlights": ["亮点1", "亮点2"]
}}"""
data = await chat_json(
"你是专业网文编辑。审核严格但建议具体。只输出 JSON。",
prompt,
temperature=0.4,
max_tokens=2500,
timeout=120,
)
if data.get("error"):
raise AIServiceError(str(data.get("error")))
data.setdefault("issues", [])
data.setdefault("highlights", [])
return data
这里我刻意让审核输出结构化 JSON。
原因是审核结果后面还要被其他能力消费。
比如:
前端展示问题列表
判断是否需要自动修复
生成修复 prompt
统计问题数量
进入任务报告
如果审核只返回一段自然语言,就很难进入流程。
所以审核工具的边界是:
只产生结构化判断,不直接修改正文。
这也是为什么我不建议把“审核并修复”混成一个工具。
审核和修复必须拆开。
审核是判断。
修复是变更建议。
它们的风险不同,失败处理也不同。
第三类:修复工具,生成修订但不绕过版本约束
修复看起来和写作很像,都是生成文本。
但它的边界更敏感。
因为修复不是从零生成,它要基于原文和审核意见改内容。
如果修复工具直接覆盖正文,会有几个问题:
用户当前编辑内容可能已经变了
审核意见对应的原文可能过期
多个提案可能同时存在
修复结果可能偏离原剧情
失败后很难恢复原文
所以修复工具不能简单设计成:
fix_chapter(content, issues) -> save(content)
当前 _fix_chapter 的职责其实只是生成修订文本:
async def _fix_chapter(task: AgentTask, content: str, issues: list[dict[str, Any]]) -> str:
issue_lines = []
for issue in issues[:10]:
issue_lines.append(
f"- [{issue.get('severity', '')}/{issue.get('type', '')}] "
f"{issue.get('problem', '')} -> {issue.get('suggestion', '')}"
)
prompt = f"""请根据编辑意见重写第{task.target_chapter}章,保留原剧情走向和人物关系。
编辑意见:
{chr(10).join(issue_lines)}
原文:
{content[:7000]}
要求:
1. 只输出修订后的正文。
2. 修复上述问题,保留章末钩子。
3. 不要输出说明、列表或 Markdown。"""
return (
await chat_text(
"你是网文终审编辑,修稿严格,输出只保留正文。",
prompt,
temperature=0.45,
max_tokens=6000,
timeout=180,
task="writing",
novel_id=task.novel_id,
)
).strip()
注意,它只返回修订正文。
真正写入章节时,要经过章节修改服务。
ChapterMutationService 会处理 revision、content hash、版本冲突和旧提案失效。
@staticmethod
async def mutate_content_in_transaction(
session: AsyncSession,
chapter: Chapter,
*,
content: str,
source: str,
expected_revision: int | None = None,
mutation_id: str | None = None,
preserve_proposal_id: int | None = None,
) -> ChapterMutationResult:
normalized = normalize_content(content)
current_content = normalize_content(chapter.content or "")
current_hash = content_hash(current_content)
if expected_revision is not None and chapter.revision != expected_revision:
raise ChapterRevisionConflict(chapter)
if current_hash == content_hash(normalized):
return ChapterMutationResult(
chapter_id=chapter.id,
content=current_content,
revision=chapter.revision,
content_hash=current_hash,
updated_at=chapter.updated_at,
mutation_id=mutation_id,
version_id=None,
)
version_id = None
if source == "conflict_overwrite":
version_number = await session.scalar(
select(func.max(ChapterVersion.version_number)).where(
ChapterVersion.chapter_id == chapter.id
)
)
version = ChapterVersion(
chapter_id=chapter.id,
version_number=(version_number or 0) + 1,
content=current_content,
word_count=_word_count(current_content),
source="conflict_overwrite",
)
session.add(version)
await session.flush()
version_id = version.id
update_values = {
"content": normalized,
"word_count": _word_count(normalized),
"revision": chapter.revision + 1,
"updated_at": datetime.utcnow(),
}
if chapter.finalized_revision is not None:
update_values["status"] = ChapterStatus.DRAFT
statement = update(Chapter).where(Chapter.id == chapter.id)
if expected_revision is not None:
statement = statement.where(Chapter.revision == expected_revision)
write = await session.execute(statement.values(**update_values))
if write.rowcount != 1:
await session.refresh(chapter)
raise ChapterRevisionConflict(chapter)
chapter.content = normalized
chapter.word_count = update_values["word_count"]
chapter.revision = update_values["revision"]
chapter.updated_at = update_values["updated_at"]
if "status" in update_values:
chapter.status = update_values["status"]
stale_proposals = update(WritingProposal).where(
WritingProposal.chapter_id == chapter.id,
WritingProposal.status.in_(("pending", "generating", "ready")),
WritingProposal.source_revision.is_not(None),
WritingProposal.source_revision != chapter.revision,
)
if preserve_proposal_id is not None:
stale_proposals = stale_proposals.where(
WritingProposal.id != preserve_proposal_id
)
await session.execute(stale_proposals.values(status="stale"))
return ChapterMutationResult(
chapter_id=chapter.id,
content=normalized,
revision=chapter.revision,
content_hash=content_hash(normalized),
updated_at=chapter.updated_at,
mutation_id=mutation_id,
version_id=version_id,
)
这段代码背后的边界是:
AI 负责生成修复建议
业务服务负责检查版本
章节服务负责真正写入
提案机制负责人工确认和撤销
也就是说,修复能力不能直接越过业务写入边界。
第四类:记忆提取,只能基于确认后的内容
记忆提取是最容易被低估的能力。
很多系统会在模型生成正文后,立刻把内容丢进记忆。
但这其实很危险。
因为生成内容不一定最终被用户接受。
如果草稿还没确认,就提取进长期记忆,后面 Agent 可能会基于一段用户根本没采用的剧情继续生成。
这会造成长期污染。
所以在当前系统里,记忆提取的边界正在变得更严格。
在新的写作闭环里,当 Agent 创建的是可审核提案时,后续步骤会被跳过:
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)
这段逻辑说明一个重要变化:
记忆不应该从未确认草稿里提取,而应该从已应用、已定稿或可追溯版本里提取。
系统里也有专门的记忆任务。
它会基于 ChapterVersion、finalized_revision、content_hash 等信息确认来源。
核心实现大概是这样:
async def run_memory_claim(
session_factory: async_sessionmaker[AsyncSession],
dispatcher: "AgentDispatcher",
claim: "TaskClaim | None",
*,
memory_extractor: MemoryExtractor = default_memory_extractor,
) -> bool:
"""Extract one immutable finalized version under an active memory lease."""
if claim is None:
return False
async with session_factory() as session:
task = await session.get(AgentTask, claim.task_id)
task_input = task.input or {} if task else {}
chapter_id = task_input.get("chapter_id")
revision = task_input.get("finalized_revision")
version_id = task_input.get("finalized_version_id")
expected_hash = task_input.get("content_hash")
version = await session.get(ChapterVersion, version_id) if version_id else None
if (
task is None
or not isinstance(chapter_id, int)
or not isinstance(revision, int)
or version is None
or version.chapter_id != chapter_id
or version.source != "finalize"
or content_hash(version.content) != expected_hash
):
await _finish_memory_task(session_factory, claim, error="memory_source_invalid")
return False
if not await dispatcher.renew(claim.task_id, claim.lease_token):
return False
try:
result = await memory_extractor(
novel_id=task.novel_id,
chapter_number=task.target_chapter,
chapter_text=version.content,
chapter_id=chapter_id,
finalized_revision=revision,
)
except Exception as error:
await _finish_memory_task(session_factory, claim, error=str(error))
return False
if not await dispatcher.renew(claim.task_id, claim.lease_token):
return False
return await _finish_memory_task(session_factory, claim, result=result)
async def _finish_memory_task(
session_factory: async_sessionmaker[AsyncSession],
claim: "TaskClaim",
*,
result: dict[str, Any] | None = None,
error: str | None = None,
) -> bool:
async with session_factory() as session:
values: dict[str, Any] = {
"status": "completed" if error is None else "failed",
"finished_at": datetime.utcnow(),
"lease_expires_at": None,
}
if error is None:
task = await session.get(AgentTask, claim.task_id)
if task is not None:
await _enrich_finalized_summary(session, task, result or {})
values["result"] = {**(task.result or {}), "memory": result or {}}
else:
values["error"] = error
completed = await session.execute(
update(AgentTask)
.where(
AgentTask.id == claim.task_id,
AgentTask.status == "running",
AgentTask.lease_token == claim.lease_token,
)
.values(**values)
)
if completed.rowcount != 1:
await session.rollback()
return False
await session.commit()
return error is None
这样做的目的不是复杂化流程,而是防止长期记忆被草稿污染。
记忆提取的边界是:
输入必须是确认内容
来源版本必须可追溯
失败不能影响正文写入
重试不能重复污染记忆
这和普通“生成后顺手总结一下”完全不是一回事。
能力拆分后,Agent 流程会更清楚
把写作、审核、修复、记忆提取拆开之后,Agent 流程就清晰很多。
不是一个大工具:
write_and_fix_and_remember()
而是一组有边界的能力:
generate_draft 生成候选正文
review_chapter 结构化审核问题
fix_chapter 基于问题生成修订建议
extract_memory 从确认版本提取长期记忆
每个能力都回答三个问题:
它输入什么?
它输出什么?
它能不能写业务数据?
我觉得这是 Agent 工具设计最重要的检查方式。
如果一个工具说不清这三个问题,就不应该急着暴露给模型。
为什么写作结果要走提案
当前项目里,一个很重要的演进是:写作工作区逐步从“AI 直接改正文”迁移到“AI 生成可审核提案”。
这和工具边界关系很大。
直接改正文的问题是:
用户看不到修改意图
版本冲突难处理
多个 AI 操作容易互相覆盖
失败后不容易恢复
用户不一定接受 AI 生成结果
所以系统里引入了 WritingProposal。
创建提案时,不只是插入一条 WritingProposal。
它会同时做几件事:
校验 request_key,避免重复请求生成多个提案
校验章节 revision,避免基于过期正文生成建议
记录 source_revision 和 source_content_hash
创建 AgentTask,交给后台 worker 生成内容
创建 AgentStep,让前端能看到执行进度
绑定上下文快照,保证生成来源可追溯
核心实现如下:
async def create_pending(
self,
novel_id: int,
chapter_id: int,
*,
kind: str,
expected_revision: int,
request_key: str,
proposal_metadata: dict,
start_offset: int | None = None,
end_offset: int | None = None,
selection_hash: str | None = None,
) -> WritingProposal:
# 质量修复必须从质检来源创建,不能走普通手工提案入口。
if kind == "quality_fix":
raise ProposalTransitionError("quality_fix_requires_scan_provenance")
if proposal_metadata.get("source_kind") not in {None, "manual"}:
raise ProposalTransitionError("manual_source_kind_required")
proposal_metadata = {**proposal_metadata, "source_kind": "manual"}
async with self._session_factory() as session:
request_fingerprint = _command_fingerprint(
{
"kind": kind,
"expected_revision": expected_revision,
"metadata": proposal_metadata,
"start_offset": start_offset,
"end_offset": end_offset,
"selection_hash": selection_hash,
}
)
try:
claim = await self._receipts.claim_command(
session,
CommandIdentity(
operation_key=request_key,
command_type="proposal_create",
resource_key=f"chapter:{novel_id}:{chapter_id}",
request_fingerprint=request_fingerprint,
),
chapter_id,
)
except ReceiptIdentityMismatch as error:
raise ProposalRequestKeyReuse("request_key_reuse") from error
existing = await session.scalar(
select(WritingProposal).where(WritingProposal.request_key == request_key)
)
if existing is not None:
if (
existing.novel_id,
existing.chapter_id,
existing.kind,
existing.source_revision,
existing.proposal_metadata,
existing.start_offset,
existing.end_offset,
existing.selection_hash,
) != (
novel_id,
chapter_id,
kind,
expected_revision,
proposal_metadata,
start_offset,
end_offset,
selection_hash,
):
raise ProposalRequestKeyReuse("request_key_reuse")
return existing
if claim.replayed:
raise ProposalRequestKeyReuse("request_key_replay_missing")
chapter = await session.scalar(
select(Chapter).where(
Chapter.id == chapter_id,
Chapter.novel_id == novel_id,
)
)
if chapter is None or chapter.revision != expected_revision:
raise ProposalSourceConflict("chapter_revision_conflict")
start_offset, end_offset, selection_hash = _validated_selection_contract(
kind,
chapter.content or "",
start_offset,
end_offset,
selection_hash,
)
proposal = WritingProposal(
novel_id=novel_id,
chapter_id=chapter_id,
kind=kind,
status="pending",
source_revision=chapter.revision,
source_content_hash=content_hash(chapter.content or ""),
start_offset=start_offset,
end_offset=end_offset,
selection_hash=selection_hash,
proposal_metadata=proposal_metadata,
request_key=request_key,
)
session.add(proposal)
await session.flush()
task = await build_agent_task(
session,
novel_id=novel_id,
task_type="write_chapter_proposal",
status="pending",
target_chapter=chapter.chapter_number,
input={
"proposal_id": proposal.id,
"metadata": proposal_metadata,
"snapshot_command": {
"proposal_kind": kind,
"source_content": chapter.content or "",
"start_offset": start_offset,
"end_offset": end_offset,
"selection_hash": selection_hash,
"metadata": proposal_metadata,
},
},
result={"proposal_id": proposal.id},
dedupe_key=f"proposal:{proposal.id}",
)
session.add(task)
await session.flush()
proposal.task_id = task.id
snapshot = await bind_task_context_snapshot(
session, task, purpose="writing_proposal"
)
if snapshot is not None:
proposal.context_snapshot_id = snapshot.id
session.add_all(
[
AgentStep(
task_id=task.id,
step_key="build_context",
title="构建上下文",
sort_order=1,
),
AgentStep(
task_id=task.id,
step_key="plan_chapter",
title="规划章节",
sort_order=2,
),
AgentStep(
task_id=task.id,
step_key="generate_proposal",
title="生成提案",
sort_order=3,
),
]
)
await self._receipts.store_response(
session,
claim.receipt,
http_status=200,
response={"proposal_id": proposal.id, "task_id": task.id},
)
await session.commit()
await session.refresh(proposal)
return proposal
这意味着 AI 生成结果不是直接覆盖章节,而是先变成一个可审核对象。
用户或业务命令决定是否应用。
应用时再检查 revision 和 hash 是否还匹配。
这就是从 Agent 到业务写入之间的一道安全闸门。
我现在更倾向于把高风险写操作都做成这种模式:
Agent 生成建议
系统保存提案
用户或规则审核
命令应用变更
版本机制兜底
这样 Agent 的价值没有丢,但业务数据不会被模型静默污染。
和工具调用的关系
回到工具调用本身。
在这个系统里,真正适合先暴露给 Agent 的,是这类工具:
读取上下文
读取章节窗口
读取大纲窗口
搜索写作技能
读取风格规则
生成结构化计划
生成审核意见
不适合直接暴露的,是这类能力:
覆盖正文
应用提案
修改大纲
修改人物状态
修改世界规则
写入长期记忆
这些能力不是不能自动化。
而是不能直接交给模型自由调用。
它们应该走任务状态、提案审核、版本检查、幂等命令和审计日志。
所以我对 Agent 工具调用的理解是:
Tool Calling 解决的是模型如何调用能力,工具边界解决的是模型被允许调用哪些能力。
前者是技术机制。
后者才是架构设计。
工具边界的几个原则
结合这次项目,我现在会用这几个原则设计 Agent 工具。
第一,读写分离。
读取上下文可以先开放,写入业务数据必须谨慎。
第二,生成和应用分离。
模型可以生成正文、修复建议和审核意见,但是否应用要走业务命令。
第三,短期结果和长期状态分离。
草稿、提案、审核意见是短期结果;记忆、人物状态、世界规则是长期状态。长期状态必须基于确认事实。
第四,能力和流程分离。
写作、审核、修复、记忆提取是能力;什么时候调用、失败怎么办、是否重试,是流程编排。
第五,工具结果必须结构化。
审核问题、修复建议、记忆提取结果,都应该尽量结构化,方便后续流程消费和审计。
第六,高风险能力必须可回滚或可拒绝。
如果一个工具的结果无法撤销、无法预览、无法审核,就不应该直接暴露给 Agent。
和前两篇的关系
第一篇讲的是:为什么要把 Agent 做进真实业务系统,而不是停留在 AI 写作按钮。
第二篇讲的是:Agent 判断之前,必须先把上下文工程做好。
这一篇讲的是:Agent 拿到上下文之后,能调用哪些能力,不能直接调用哪些能力。
可以简单串起来:
上下文工程:Agent 知道什么
工具边界:Agent 能做什么
任务状态机:Agent 做到哪一步
提案机制:Agent 结果怎么进入业务
这也是后面几篇的顺序。
下一篇就应该讲任务状态机。
因为一旦工具和能力边界拆清楚,接下来真正的问题就是:
这些步骤怎么持久化?
怎么取消?
怎么重试?
服务重启怎么办?
前端怎么知道 Agent 跑到哪一步?
这就是 AgentTask 和 AgentStep 要解决的问题。
最后
这篇文章主要讲工具调用设计。
我的结论很简单:
Agent 工具不是越多越好,边界越清楚越好。
尤其是在真实业务系统里,不能因为模型能调用函数,就把所有业务接口都暴露出去。
写作、审核、修复、记忆提取这些能力,必须按风险拆开。
写作生成候选内容。
审核输出结构化判断。
修复生成可审阅改动。
记忆只从确认内容里提取。
只有这样,Agent 才不是一个权限过大的自动脚本,而是一个可以被业务系统接住的执行者。
下一篇我会继续拆任务状态机:AgentTask 和 AgentStep 如何记录 Agent 的执行过程,以及取消、重试、中断恢复这些看起来“不 AI”的能力,为什么反而是 Agent 落地的关键。