Amoxi 将请求保存为任务,由后台工作进程执行,并在模型轮次和工具结果后写入检查点,以支持重启恢复。教程围绕退款与邮件场景处理副作用,并通过 MCP、A2A 向其他 AI 客户端开放 Agent。
持久化运行可以从中断处继续,副作用不会重复发生,还能通过 MCP 和 A2A,让 Claude、ChatGPT 以及其他 Agent 调用你的 Agent。
在第 5 篇文章中,每次运行大约只需要一秒。真实的 Agent 任务要慢得多:多次模型调用、支付服务商、邮件服务器。在这几分钟里,某次部署可能会重启服务器,进程也可能崩溃。这时,正在执行的任务怎么办?客户的退款到底退了一次、两次,还是一次都没退?
本文继续使用第 5 篇文章中的客服助手。我们会添加一个退款工具,在退款过程中强行终止服务器,然后把这个 Agent 开放给其他 AI 客户端。
收到消息后,Amoxi 不会在 HTTP 请求中执行 Agent。它会保存一个运行任务,然后立即返回;后台 worker 负责执行,浏览器则通过事件流跟踪进度。每轮模型交互和每次工具返回结果后,运行任务都会向存储写入一个检查点,默认保存在 data/ai/ 目录下的文件中。
正是这些检查点,让运行任务具备了持久性。如果进程退出,另一个 worker,或者重启后的同一台服务器,就能从最后一个检查点接着执行。
退款有两个副作用:资金转移,以及向客户发送邮件。创建 src/orders/payments.ts,用它模拟支付服务商,把每笔退款写入文件,方便统计退款次数:
import { appendFileSync, mkdirSync } from 'node:fs';
import { Singleton } from 'amoxi';
/** Stands in for a payment provider: every refund is appended to data/refunds.log. */
@Singleton()
export class Payments {
refund(orderId: string, idempotencyKey: string): { refundId: string } {
mkdirSync('data', { recursive: true });
const refundId = `R-${Date.now()}`;
appendFileSync('data/refunds.log', `${refundId} ${orderId} key=${idempotencyKey}\n`);
return { refundId };
}
}
接着是工具 src/orders/refund-tools.ts:
import { Singleton } from 'amoxi';
import { Tool, type ToolContext } from 'amoxi/ai';
import { Payments } from './payments';
@Singleton()
export class RefundTools {
constructor(private readonly payments: Payments) {}
/** Refund an order and email the customer a receipt. */
@Tool({ idempotent: true })
async refundOrder(
input: {
/**
* The order number, e.g. A-1001
* @pattern ^A-\d{4}$
*/
orderId: string;
},
ctx: ToolContext,
): Promise<{ refundId: string; emailed: boolean }> {
const { refundId } = await ctx.step('refund', () => this.payments.refund(input.orderId, ctx.callId));
ctx.progress('Refund done, sending the receipt...');
await ctx.step('email', async () => {
await new Promise((r) => setTimeout(r, 4000)); // a slow mail server
console.log(`receipt for ${refundId} sent`);
return true;
});
return { refundId, emailed: true };
}
}
把 RefundTools 加入 Agent 的工具列表。这个工具里有三点很关键:
ctx.step('refund', fn) 会执行 fn,并把结果记录到运行任务中。如果工具在进程崩溃后再次执行,已经完成的步骤会直接返回记录的结果,不会重新执行。
@Tool({ idempotent: true }) 告诉 Amoxi,整个工具可以安全地重新执行,因为它的副作用都封装在步骤里。
ctx.callId 会作为幂等键传给支付服务商。中断的调用重试时,这个值保持不变;每次新调用则会得到不同的值。大多数支付 API 都接受这样的键,即使进程在调用支付服务商的过程中崩溃,它也能提供保护。
ctx.progress(...) 会在耗时操作执行期间,在聊天界面显示一行状态信息。
启动应用,输入“为订单 A-1001 退款”,然后在收据还处于“发送中”时强行终止进程(Ctrl+C 太温和了;可以关闭终端,或者使用任务管理器、kill -9)。此时,退款已经记录下来:
R-1792425180411 A-1001 key=offline_1
再次启动应用。等已经退出的 worker 对运行任务的占用租约到期后,任务就会恢复执行。租约默认是 30 秒,由 ai.runs.lease-ms 配置。工具会再次运行:退款步骤直接返回之前记录的结果,因此不会再次退款;尚未完成的邮件步骤则会在这时执行:
receipt for R-1792425180411 sent
data/refunds.log 仍然只有一行记录,运行任务最终返回的也是最初的退款 ID。对于看着聊天界面的用户来说,任务会继续执行,仿佛什么都没发生;浏览器会自行重新连接事件流。
如果没有把工具标记为幂等,会怎样?Amoxi 绝不会盲目重新执行中断的调用。它会告诉模型,这个操作可能已经完成,也可能没有完成,模型可以进行核查,例如查询订单,也可以询问用户。对于尚未实现幂等性的工具,这是安全的默认行为。
POST /agents/support-agent/runs/<id>/cancel 可以停止运行任务,即使任务正在执行工具。工具会收到 ctx.signal,这是一个标准的 AbortSignal;把它传给 fetch 和其他接受该信号的调用,这些操作也能随之停止。
文件存储适合单台服务器。如果需要多台服务器,就切换到带有 pgvector 的 PostgreSQL:
npm install pg
在 application.json 中设置 "ai": { "store": { "type": "postgres" } },并提供 DATABASE_URL。这样,运行任务就会组成一个共享队列:任意服务器都可以领取等待中的任务,而以 ai.runs.worker=false 启动的服务器只负责处理 Web 流量。Agent 的代码无需修改。
Model Context Protocol(MCP)让 Claude、ChatGPT、Cursor 和 IDE 助手等 AI 客户端能够调用服务器上的工具。每个 Amoxi 应用都可以成为这样的服务器。为每个要连接的客户端生成一个 token。下面的命令在 bash 和 PowerShell 中都能运行:
node -e 'console.log(require(`crypto`).randomBytes(32).toString(`hex`))'
在 application.json 中启用 MCP:
{ "ai": { "mcp": { "enabled": true } } }
然后,通过环境变量 AI_MCP_CALLERS_<NAME> 为每个调用方分别配置 token,例如用 AI_MCP_CALLERS_DESKTOP 配置桌面客户端。不要把 token 放进 application.json,也不要提交到 git。如果 token 少于 16 个字符,Amoxi 会拒绝启动。
现在,任何支持远程服务器的 MCP 客户端,都可以带上请求头 Authorization: Bearer <token>,连接到 http://localhost:8080/mcp。没有 token,会返回 401;带上 token 后,tools/list 会返回:
list_notes readOnlyHint
add_note
lookup_order readOnlyHint
refund_order idempotentHint
ask_assistant
ask_support_agent
留意哪些内容出现了,哪些没有:
你的工具会带上从装饰器提取的提示:readOnly 变成 readOnlyHint,idempotent 变成 idempotentHint。
cancel_order 没有出现。需要审批的工具绝不会通过 MCP 对外提供,因为那里没有人来审批。
每个 Agent 都会以 ask_<agent> 工具的形式出现,因此客户端可以把整个问题委派给它。
refund_order 被直接提供给了客户端。对于一个涉及资金转移的工具,这可能并不是你想要的行为。可以用 @Tool({ idempotent: true, expose: false }) 把它隐藏起来,Agent 仍然可以使用它。
调用 lookup_order 时,返回的订单既有文本形式,也有 structuredContent 形式,后者的类型来自你的返回类型。
如果客户端要求 Agent 执行一个需要审批的操作呢?
ask_support_agent { "message": "Cancel order A-1002" }
-> "That didn't work: The person declined this action."
Agent 会获知原因,并据此作答。如果 MCP 调用方是已经登录你应用的用户,可以把 ai.mcp.approvals 设置为 wait。这样,运行任务就会暂停,直到该用户在应用中批准操作。
A2A 协议用于 Agent 之间的通信,例如,合作伙伴的采购 Agent 向你的客服 Agent 查询订单。启用方式与 MCP 类似:
{ "ai": { "a2a": { "enabled": true } } }
通过 AI_A2A_CALLERS_<NAME> 为每个合作伙伴配置一个 token。其他 Agent 可以通过 /.well-known/agent-card.json 发现你的 Agent。这个文件把整个应用描述为一个 A2A Agent,每个 Amoxi Agent 对应其中的一项 skill:
{ "name": "agent-app", "url": "http://localhost:8080/a2a", "skills": ["assistant", "support-agent"] }
合作伙伴向某项 skill 发送任务:
{ "jsonrpc": "2.0", "id": 1, "method": "message/send",
"params": {
"message": { "role": "user", "messageId": "m1", "parts": [{ "kind": "text", "text": "Where is order A-1001?" }] },
"metadata": { "skill": "support-agent" } } }
随后收到一个已完成的任务,答案作为 artifact 返回:
Here's what I found:
ID: A-1001
Item: Trail backpack, 28 L
Status: shipped
运行任务映射为 A2A task,对话映射为 context,审批则映射为 input-required 状态。
每个调用方都有自己的 token,因此 Amoxi 知道请求来自谁。携带 token 发起请求的调用方,会以 a2a:partner 或 mcp:desktop 这样的身份拥有自己的对话。它只能列出和继续自己的任务,Agent 对某个调用方保留的记忆也只属于该调用方。你的工具同样可以区分渠道:通过 MCP 或 A2A 启动的运行任务,会携带 principal,分别为 { channel: 'mcp' } 或 { channel: 'a2a' }。
MCP 和 A2A 默认都处于关闭状态,而且你自己的中间件会先于它们执行,因此可以像处理其他路由一样,添加 IP 规则或限流。
运行任务是后台作业,每一步之后都会保存检查点,并能在崩溃或重启后恢复。
ctx.step 让副作用只发生一次;ctx.callId 则提供了一个现成的幂等键。
对于不具备幂等性的工具,系统绝不会盲目重新执行。
只需启用相应配置,就能让应用成为 MCP 服务器和 A2A Agent,并为每个调用方提供独立的 token 和隔离的对话。
需要审批的工具绝不会直接提供给外部客户端,其他工具也可以通过 expose: false 隐藏。
最后一篇文章会把应用带到生产环境:在每个 CPU 核心上运行 amoxi start,使用 Docker,管理密钥和配置,执行健康检查,以及在第一位真实用户到来前逐项核对的检查清单。
Amoxi 基于 Apache License 2.0 开源。代码:github.com/karawash-projects/amoxi。文档:karawash-projects.github.io/amoxi。
如果需要采取进一步措施,可以考虑屏蔽此人,或者举报其滥用行为。