首页 / 文章

从“开始执行”到完成:代理行为的状态、确认与幂等性

了解如何通过明确的提案、受限的审批流程、重新验证机制、幂等性键以及结果验证,将多智能体退款工作流转变为值得信赖的流程。

发布者:REACTAPP.TOP阅读约 14 分钟3113 词

来源: The Architecture of a Multi-Agent System That Can Actually Take Action

多智能体演示通常以一个令人信服的答案作为结尾。而实际生产系统则要在用户回复“很好,继续”之后立即开始最艰巨的工作——软件必须将对话中的建议转化为另一系统中的实际变更。本文通过一个退款请求的完整流程,展示了协调器、专业智能体、存储的方案、人工审批与执行等环节,同时说明了哪些职责应由模型承担,哪些则必须留在应用程序代码中。读完本文后,你将拥有一个具体的检查清单,用于判断某个智能体工作流是否已准备好对现实世界采取行动。

当助手需要执行操作时会发生什么变化

在获得批准之前,助手只需理解请求并给出有用的回复。一旦用户同意,就需要承担另一系列职责:系统必须准确回忆自己所提供的内容,找到正确的记录,挑选负责执行该任务的组件,确认该操作仍然合理,检查该用户是否有权限授权,调用外部服务,处理故障,最后还要确定一件看似简单但实际上很少能做到的事情——即该操作是否真的已经执行。

常见的演示架构仅涵盖第一部分。协调器接收请求后将其分配给各专业人员,这些专业人员再调用相应工具,最终得到回复。这种结构固然有用,但对于实际操作而言它仅仅是一个入口点。要实现安全处理,还需要明确的系统状态、授权机制、能够承受等待与重新验证的工作流、幂等性设计、从模糊故障中恢复的能力,以及确认任务已完成的依据。正是在这一点上,代理设计才从提示问题转变为架构问题。

追踪某项退款请求

以客户可能发送给客服助手的请求为例:查看订单号4821,判断其是否符合退款条件,若符合则起草退款申请以供批准。人工客服会将此过程分解为大约十个步骤:

  1. 弄清楚客户的需求是什么。
  2. 调取该订单信息。
  • 查找适用的退款政策。
  • 将订单与政策进行比对。
  • 解决所有尚未明确的问题。
  • 计算退款金额。
  • 起草退款方案。
  • 申请批准。
  • 执行退款操作。
  • 确认退款是否成功。
  • 在软件系统中,每一个流程转换都需要负责人和对应的处理环节。一个合理的流程会将用户的请求传递给协调员,再由专门人员收集证据并起草方案,随后方案经过批准、执行,最后进行验证。

    • 请求的路由分配是协调员的工作。
    • 专门人员负责相关领域的调查。
    • 工具用于将系统与文档及实时记录相连。
    • 明确的状态能够体现拟采取的行动。
    • 用户会对某个具体的方案进行批准。
  • 应用程序代码负责执行操作。
  • 验证环节用于确认是否达到了预期的结果。
  • 有一个核心原则将它们联系在一起:

    模型决定应当发生什么;应用程序则决定允许发生什么。

    以下的代码片段是使用 LlamaIndex 编写的 Python 架构示例。其中未涉及模型配置、存储及服务集成等内容,llm 和 policy_retriever 等名称代表应用程序需实现的组件。部分代码片段的行间距已被压缩(即多条语句写在同一行),因此应将其视为结构概要而非可直接复制粘贴的代码。

    将路由处理与专业功能分离

    构建智能体的最快方法是一次性为其提供所有工具:文档搜索、订单查询、付款核查、退款处理、邮件功能以及报告生成。对于小型应用来说,这种做法完全合理。但随着职责的增多,智能体的工作就难以用一句话概括了,此时处理退款的任务不得不与处理资金转账的操作共享同一个功能工具箱。

    更清晰的划分方式是将两项任务分开:确定请求所属的类别,以及针对该类别执行专门的操作。对于客服助手而言,这意味着要设置一名协调员来处理一般性问题,并将与退款相关的请求转交给专门的专家。该专家则是基于LlamaIndex的FunctionAgent构建的:

    from llama_index.core.agent.workflow import FunctionAgent
    

    其定义赋予了它有限的职责:三种工具以及将控制权交还给协调器的权限。请注意,系统提示要求它在提出任何建议之前先解释自己的依据:

    refund_specialist = FunctionAgent(
        name="refund_specialist",
        description="Checks refund eligibility and prepares proposals.",
        system_prompt=(
            "Check the order and the applicable policy. "
            "Explain your evidence before proposing a refund. "
            "Hand back unrelated requests to the coordinator."
        ),
        tools=[get_order, search_policy, prepare_refund],
        llm=llm,
        can_handoff_to=["coordinator"],
    )
    

    description字段并非装饰性内容,而是架构的一部分。诸如“实用助手”之类的标签无法传达任何信息。“核查退款资格并准备方案”则明确界定了可被测试和审计的职责。在LlamaIndex中,FunctionAgent和AgentWorkflow类正是为这种明确的职责交接而设计的。如果您仍在选择框架,LangChain与LlamaIndex的对比介绍了更广泛的权衡因素。

    增加代理并不一定意味着改进。每个专家都会带来协调成本,因此每一个代理的存在都应有其合理性。在这里,由一名协调员和一名退款专家组成的组合能够在不增加额外成本的情况下划出明确的职责边界。

    将协调员与专家连接起来

    协调员以及连接这两名代理的工作流都来自同一个模块:

    from llama_index.core.agent.workflow import AgentWorkflow
    

    协调员可以查询政策以处理一般性问题,必要时可将其转交给退款专家。AgentWorkflow负责注册这两名代理,并使协调员成为接收所有请求的根节点。在代码片段中,协调员的结束括号与工作流分配语句被写在同一行上;实际上它们是两个独立的语句:

    coordinator = FunctionAgent(
        name="coordinator",
        description="Answers general questions and routes refund requests.",
        system_prompt=(
            "Use documentation for general questions. "
            "Hand refund requests to the refund specialist."
        ),
        tools=[search_policy],
        llm=llm,
        can_handoff_to=["refund_specialist"],
    )workflow = AgentWorkflow(
        agents=[coordinator, refund_specialist],
        root_agent="coordinator",
    )
    

    如此一来,请求就有了具体的路径。协调员会识别出这是退款相关的问题并将其转交;专员则会调取订单信息,查阅相关政策,记录下还缺失的资料,一旦证据足够,就会起草一份解决方案。

    对话的流程会有所不同:有的客户会提前告知配送日期;有的则先询问退款规则,三条消息之后才提到订单。客服人员能够适应这些差异,而系统则继续明确现有的功能及其使用限制。

    对话可以保持灵活性,而关键操作则需受到控制。

    资格审核前的证据收集

    将请求转交给合适的客服人员,并不意味着该人员的结论就一定是正确的。专员仍需收集相关证据。

    假设政策允许未开封的产品在30天内退回,而订单记录显示编号为4821的订单是在12天前送达的。这些信息来自两个不同的系统,但两者都很重要:政策规定了规则,订单描述了实际情况,只有将二者结合才能确定是否符合退回条件。因此,信息检索便成了工作流程中的一环。专员在查询政策文件的同时,会单独加载当前的订单记录。

    一个最简单的政策查询工具始于异步签名以及一段说明该工具用途的文档字符串:

    async def search_policy(question: str) -> list[dict]:
        """Find policy passages relevant to a customer request."""
    

    工具主体会检索出匹配的内容,并将每段内容连同其来源标题和章节一起返回。与之前的代码片段一样,aretrieve调用和return语句应分别写在不同的行上:

        matches = await policy_retriever.aretrieve(question)    return [
            {
                "text": match.node.get_content(),
                "source": match.node.metadata.get("title"),
                "section": match.node.metadata.get("section"),
            }
            for match in matches
        ]
    

    重要的是与文本一同存在的信息:其来源。如果客服后来声称订单仍在退款期限内,其解释应当能够引用定义该期限的政策条款。不过,来源信息也存在局限性:

    引用并不能证明某种解读是正确的;它只是让他人能够去核实。

    未开启的状态存在一个漏洞:订单系统中没有任何记录表明包裹是否已被打开。正确的做法不是猜测,而是询问客户。优秀的设计会明确指出缺失的信息,而非让不确定性演变成看似确定的建议。

    对话历史并非应用状态

    假设调查已经完成。专家指出订单#4821符合79欧元的退款条件,并询问是否继续处理。用户回复同意。

    路由功能正常,调查也已完成,相关工具也提供了证据,但仍存在一处缺失。系统无法准确说明究竟批准了什么:订单内容、金额及货币种类,以及优惠版本等信息都只是隐含存在而非被明确记录下来。如果对话中涉及了两笔订单,或者优惠之后金额又被重新计算过,那么“同意”这个答复就会变得含糊不清。

    正因如此,关键操作绝不能仅存在于聊天记录中。应用程序需要有一份明确的待办操作记录,就像这样:

    pending_action = {
        "proposal_id": "refund-proposal-17",
        "order_id": "4821",
        "amount_minor": 7900,
        "currency": "EUR",
        "status": "awaiting_approval",
        "evidence": [
            "returns-policy:section-3",
            "order:4821",
        ],
    }
    

    status字段虽有用,但真正起决定作用的是proposal_id。审批结果应指向用户所看到的具体提案。如果金额发生变化,那就是新的提案;如果顺序发生变化,也是新的提案;如果有新证据改变了推荐意见,同样属于新提案。金额以整数分的形式存储在amount_minor中,这样可以避免浮点数运算带来的四舍五入问题,而evidence列表则记录了是哪一部分政策及顺序记录支撑了该提议。

    对话内容用于辅助理解,而结构化的状态数据则用于实际操作。

    对话有助于模型理解“批准”具体指什么,而存储的提案则为应用程序提供了明确可执行的依据。

    以数据而非文字形式进行审批

    记录同意信息的简单方式仅记录一个事实:

    user said yes
    

    更安全的模型会将身份、提议、操作及上下文结合在一起:

    user X approved proposal Y,
    containing action Z,
    under the current conditions.
    

    一旦代理能够影响外部系统,这种区分就变得至关重要。同意信息应将特定个人与特定的提议操作关联起来,而非与该模型事后重建的内容相关联。因此,该提议必须包含所有重要的操作细节:

    • 目标记录
    • 金额
    • 货币类型
    • 支持性证据
    • 当前状态
    • 唯一的提议标识符

    自然语言用于向用户解释操作内容;而结构化的提议则用于为系统定义该操作。

    等待也是工作流程的一部分

    人工审批也会耗费时间。用户可能立即回复,也可能在午饭后或关闭浏览器后的次日才回应。当工作流依赖于人工时,暂停并非异常情况,而是一个正常阶段,因此待处理的工作必须被保存以便日后继续处理。

    LlamaIndex提供了工作流上下文对象以及用于暂停等待人工输入的功能,从而为这种交互带来一定的结构化。不过,持久存储、授权机制、过期规则以及相关的应用程序生命周期管理仍需由您负责。

    对于退款这类至关重要的操作,合理的职责划分应是让智能体准备处理方案,再由普通应用程序代码来执行已获批准的操作。审批处理程序首先会加载存储好的处理方案:

    async def approve_refund(proposal_id, user):
        proposal = await proposals.load(proposal_id)
    

    接着它会检查权限,确认提案仍处于待处理状态,重新验证该提案,使用提案编号作为幂等性键调用支付服务,并记录接收凭证。在这段代码中,这些异步调用在同一行上依次执行;每个await都是独立的语句:

        await permissions.require(
            user,
            "approve_refund",
            proposal,
        )    await proposals.require_pending(proposal)    await refunds.revalidate(proposal)    receipt = await payments.refund(
            order_id=proposal.order_id,
            amount_minor=proposal.amount_minor,
            idempotency_key=proposal.id,
        )    await proposals.mark_completed(
            proposal.id,
            receipt,
        )    return receipt
    

    那个简短的函数中隐藏着若干边界条件:

    • 提案来自存储状态,而非对话内容。
    • 授权检查在模型外部进行。
    • 代码会确认提案仍在等待批准,以此防止通过此路径再次执行(在并发环境下,该检查应与状态更新操作保持原子性)。
    • 在采取行动之前会重新验证资格条件。
    • 支付调用会使用稳定的幂等性键。
    • 操作结果会被持久化存储。

    这一流程从保存的提案开始,经过授权审批、重新验证,再到执行操作本身,最终形成存储的结果。这一流程的重要性远超过代理是否完美地表达了信息。

    执行前立即重新验证

    既然用户已经同意了,为何还要再次检查?因为系统等待的期间世界仍在变化。订单可能已通过其他客服渠道获得退款,支付状态可能已经改变,或许有人修改了订单,又或者某个并行工作流早已完成了该操作。

    提案只是在其起草时看似正确的状态快照,实际执行往往要晚得多。在进行重要操作之前,应用程序应确认提案背后的假设依然成立。

    “是”的回答意味着允许执行该操作;它并不会阻止世界发生变化。

    提案被搁置的时间越长,这一点就越重要。将重新验证与提案的过期时间相结合,可以限制过时假设的存在时长。

    重试时不得重复执行相同操作

    再考虑另一种失败情况:用户批准了请求,应用程序发送了退款信息,支付服务提供商也处理了该请求,但在响应到达之前网络就中断了。客户端于是进行重试。顾客难道应该再收到一笔79欧元的款项吗?显然不应该。

    幂等性可以避免这种情况。稳定的操作标识符能让所有支持幂等性的外部服务将两个请求视为同一个逻辑操作。该服务不会把重试理解为“再次发起退款”,而是将其视为查询与提案#17相关的操作状态或结果的请求。使用提案编号作为键是可行的,因为每个已获批的提案最多只会产生一次退款。服务器端的实现机制在Node.js POST端点中幂等性键的原理中有更详细的说明。

    在完善的多智能体演示中,这一机制很少被体现出来。但一旦系统能够转账、发送消息、修改记录、创建工单或触发任何其他实际效果,重试就成为了系统架构中不可或缺的一部分。

    “未知”也是一种合法的结果

    现在来看最令人困扰的情况:支付请求超时了。也许什么都没发生,或者款项在连接中断前刚刚转出。对客户而言,两者的差异是79欧元。

    告诉用户退款失败正在重试是不对的,因为没有任何依据支持这一说法。系统仅能说明没有收到确认信息,但这与实际情况不同。一个可靠的流程会将这种不确定性显性化:它不会重复操作,而是查询现有交易的状态,并在此期间向用户提供准确的信息,比如无法确认交易是否完成,正在检查中。

    这种规范适用于每个阶段,而不仅仅是支付时:

    • 当查询订单出现错误时,只能说明该订单的存在状态未知,而非已被否定。
  • 当策略搜索出现错误时,该策略问题会保持未解决状态,而非显示为无结果。
  • 超时并不一定意味着操作失败;只是尚未确认是否完成。
  • 可靠的系统会将这些情况视为不同的状态,而非统一归类为“失败”或“未找到”。

    返回的工具调用并不代表任务已完成

    在此架构中,工作流程已超越简单的协调层面。工具调用的返回并不等同于任务完成。出现错误响应说明未完成,超时同样表示未完成。如果应用程序无法确定退款是否已执行,那显然任务还未结束。

    更严谨的完成定义是:系统能够提供证据,证明预期的结果确实已经实现。这改变了相关约定。目标并非如此:

    call refund()
    

    目标应该是这样:

    establish that refund proposal #17
    for order #4821
    was successfully processed exactly once
    

    这些是截然不同的保障措施,正是这种差异使得协调器与专家仅是设计过程中的初始环节。

    多智能体系统投入运行前的九个问题

    在将人工智能工作流与会产生实际影响的操作相连接之前,请先思考以下问题:

    1. 每个智能体是否都有能用一句话概括的明确职责?
    2. 能否将某个重要决策追溯到其背后的文档和记录?
    3. 缺失的信息能否始终明确标注为未知状态,还是不确定因素会悄悄变成确定的建议?
    4. 拟采取的行动是否以结构化状态的形式存储,而不仅仅存在于对话中?
    5. 用户是否只批准某个具体的方案,从而让“是”这一答复真正代表对某件事的授权?
  • 权限是在应用程序代码中强制执行的,还是仅由模型负责建议?
  • 由于条件可能发生变化,是否会在执行前重新验证该提议?
  • 能否通过重试安全地完成执行,并将幂等性视为正确性的组成部分?
  • 在宣布任务完成之前,是否会先确认实际结果,而非将工具调用视为证明?
  • 如果其中一些问题没有明确答案,虽然你的演示可能仍然很出色,但恐怕还无法形成值得信赖的运行系统。

    总结

    重新查看原始请求:检查订单号4821并准备退款以供批准。现在每个环节都有了对应的处理位置。协调员负责分配任务,专家收集证据,工具将数据同步至文档和实时记录中,明确的状态标识保存着相关提案,有人会对某项具体操作进行确认,应用代码会检查权限并重新验证,支付系统执行相应操作,最终结果会被存储并确认。此时助手就可以给出一个平淡却可靠的回复:订单号4821的79欧元退款已处理完毕,附有确认信息。这句话之所以可信,正是得益于其背后的所有流程。

    随着系统的不断发展,那些问题依然具有重要意义。每项功能都有明确的负责人吗?决策背后的依据能否被审查?工作流程能否在依赖关系出问题时保留不确定性并恢复正常运行?用户能否清楚地看到自己正在批准的内容,系统又能否证明预期的结果已经实现?这些问题的答案能帮助我们判断在哪些地方需要额外的专业代理来提升价值,哪些地方普通函数就足够了,以及哪些地方必须设定更明确的界限。最佳的代理体验或许仅以一个简单的词“完成”作为结束,而架构则是让这一体验显得可信的关键。