Agent 工具调用设计:写作、审核、修复、记忆提取如何拆边界

3 阅读17分钟

上一篇文章讲的是上下文工程。

核心观点是: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)

这段逻辑说明一个重要变化:

记忆不应该从未确认草稿里提取,而应该从已应用、已定稿或可追溯版本里提取。

系统里也有专门的记忆任务。

它会基于 ChapterVersionfinalized_revisioncontent_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 跑到哪一步?

这就是 AgentTaskAgentStep 要解决的问题。

最后

这篇文章主要讲工具调用设计。

我的结论很简单:

Agent 工具不是越多越好,边界越清楚越好。

尤其是在真实业务系统里,不能因为模型能调用函数,就把所有业务接口都暴露出去。

写作、审核、修复、记忆提取这些能力,必须按风险拆开。

写作生成候选内容。

审核输出结构化判断。

修复生成可审阅改动。

记忆只从确认内容里提取。

只有这样,Agent 才不是一个权限过大的自动脚本,而是一个可以被业务系统接住的执行者。

下一篇我会继续拆任务状态机:AgentTaskAgentStep 如何记录 Agent 的执行过程,以及取消、重试、中断恢复这些看起来“不 AI”的能力,为什么反而是 Agent 落地的关键。