教程使用 Amoxi 将普通 TypeScript 方法定义为工具,由类型生成相关适配代码,搭建查询订单、经人工批准后取消订单的客服助手。内置离线模型通过关键词选择工具,支持无 API 密钥的本地演示与测试。
用普通的 TypeScript 方法作为工具,构建一个客服助手:它能查询订单,并且只有在获得人工批准后才会取消订单。
AI Agent 本质上是一个循环:模型读取对话,决定调用你的某个函数(也就是工具),查看结果,然后继续调用其他工具,或者给出回答。难点在循环之外。你需要向模型描述每个工具,校验它生成的参数,把回答以流式方式传到浏览器,还要在任何有风险的操作发生之前停下来。
在 Amoxi 中,你把工具写成普通方法,把 Agent 写成一个小类。构建过程会根据你的类型生成其余部分。接下来,我们将为一家小型户外用品商店构建客服助手。
npx create-amoxi-app support-demo --template agent
cd support-demo
npm run dev
打开 http://localhost:8080/assistant。模板中的助手已经可以列出和保存笔记。试着输入“显示我的笔记”。
你不需要 API key。没有 API key 时,Amoxi 会使用内置的离线模型,根据关键词选择工具,并从消息中提取信息来填充参数。它不算聪明,但足以让你离线完成开发、演示和测试。如果要使用真正的模型,在项目目录的 .env 文件中添加一行:
ANTHROPIC_API_KEY=your-key
也可以使用 OPENAI_API_KEY,或者通过 OPENAI_BASE_URL 接入 Ollama 等任何兼容 OpenAI 的服务。
创建 src/orders/order-tools.ts。为了让示例保持简短,订单保存在内存中;在实际应用中,这个类会调用数据库或订单系统。
import { Singleton } from 'amoxi';
import { Tool, ToolError } from 'amoxi/ai';
export interface Order {
id: string;
item: string;
status: 'processing' | 'shipped' | 'delivered' | 'cancelled';
eta?: string;
}
@Singleton()
export class OrderTools {
private readonly orders = new Map<string, Order>([
['A-1001', { id: 'A-1001', item: 'Trail backpack, 28 L', status: 'shipped', eta: '2026-10-22' }],
['A-1002', { id: 'A-1002', item: 'Camp mug, enamel', status: 'processing' }],
]);
/** Look up an order by its number and report its status. */
@Tool({ readOnly: true })
lookupOrder(input: {
/**
* The order number, e.g. A-1001
* @pattern ^A-\d{4}$
*/
orderId: string;
}): Order {
const order = this.orders.get(input.orderId);
if (!order) throw new ToolError(`There is no order ${input.orderId}.`);
return order;
}
}
这就是一个完整的工具。构建过程会根据它生成以下内容:
名称:lookup_order。
描述:来自方法的 JSDoc。
输入的 JSON Schema:根据参数类型生成,其中包含字段注释和 @pattern 规则,与第 4 篇文章中的请求校验完全相同。
校验器:如果模型发送 { "orderId": "1001" },这次调用根本不会进入你的代码。错误会返回给模型,让它自行修正。
readOnly: true 用来标记这个工具不会修改任何内容。ToolError 用于承载需要展示给模型和用户的消息,例如 "There is no order A-9999."。在生产环境中,其他错误只会以 "internal error" 加上一个引用 ID 的形式返回,避免数据库细节泄露到聊天中。
创建 src/orders/support-agent.ts:
import { Agent, type AgentRunContext } from 'amoxi/ai';
import { OrderTools } from './order-tools';
/** Answers questions about orders and cancels them on request. */
@Agent({
tools: [OrderTools],
examples: ['Where is order A-1001?', 'Cancel order A-1002'],
})
export class SupportAgent {
instructions(ctx: AgentRunContext): string {
return [
'You are the support assistant of a small outdoor shop. Be brief and friendly.',
'Always look orders up with the tools; never guess a status or a date.',
`Today is ${ctx.now.toDateString()}.`,
].join('\n');
}
}
instructions() 就是 system prompt,每次运行时都会重新生成,因此可以包含日期或用户的详细信息。Agent 的名称来自类名,这里是 support-agent;描述则来自 JSDoc。
到这里,你已经可以和它对话了。每个 Agent 都有一个 HTTP API,可以在终端中试一下(Windows PowerShell 用户请输入 curl.exe):
curl -N -X POST -H "content-type: application/json" -H "accept: text/event-stream" -d '{"input":"Where is order A-1001?"}' http://localhost:8080/agents/support-agent/runs
回答会以 Server-Sent Events 的形式流式返回。以下是精简后的内容:
event: message.delta
data: {"text":"Let "}
event: tool.started
data: {"tool":"lookup_order","input":{"orderId":"A-1001"}}
event: tool.completed
data: {"tool":"lookup_order","output":{"id":"A-1001","item":"Trail backpack, 28 L","status":"shipped","eta":"2026-10-22"}}
event: message.delta
data: {"text":"Here's "}
如果改为查询 A-9999,ToolError 会返回给模型,模型随后告诉顾客:“操作未成功:不存在订单 A-9999。”
查询信息没有什么风险,取消订单却不同,因此应该等待人工批准。在 OrderTools 中添加第二个工具:
/** Cancel an order that has not shipped yet. */
@Tool({ approval: true })
cancelOrder(input: {
/**
* The order number, e.g. A-1002
* @pattern ^A-\d{4}$
*/
orderId: string;
}): Order {
const order = this.orders.get(input.orderId);
if (!order) throw new ToolError(`There is no order ${input.orderId}.`);
if (order.status !== 'processing') throw new ToolError(`Order ${order.id} has already shipped.`);
order.status = 'cancelled';
return order;
}
设置 approval: true 后,当模型决定取消订单时,本次运行会暂停,状态变为 awaiting_approval,同时发送一个 approval.required 事件,注明工具名称和参数。此时工具尚未执行。只有收到人工回应后,运行才会继续:
POST /agents/support-agent/runs/<run id>/approvals/<call id> {"approved": true}
随后,Agent 会取消订单并报告“状态:cancelled”。如果用户拒绝,模型会收到相应通知,并据此回答。审批由服务端强制执行,因此,无论是精心构造的请求,还是判断出错的模型,都无法跳过审批。
你可以根据这些事件自行构建聊天 UI,不过 agentChat() 已经能把对话转换为 signals(见第 3 篇文章)。模板中的 src/islands/Assistant.tsx 就是一个完整的聊天界面,包含消息、工具执行步骤,以及 Allow 和 Decline 按钮。将它复制到 src/islands/SupportChat.tsx,把函数重命名为 SupportChat,再让它指向新的 Agent:
const chat = agentChat('support-agent');
然后为它添加页面 src/pages/support-page.tsx:
import { Controller, Island, Page } from 'amoxi';
import SupportChat from '../islands/SupportChat';
import { Layout } from '../ui/layout';
@Controller()
export class SupportPage {
@Page('/support')
support() {
return (
<Layout title="Support">
<h1>Order support</h1>
<Island of={SupportChat} examples={['Where is order A-1001?', 'Cancel order A-1002']} />
</Layout>
);
}
}
打开 http://localhost:8080/support,点击“Cancel order A-1002”,在任何操作执行之前,对话中都会先出现 Allow 按钮。
除了文本,Agent 也可以展示组件。在 src/islands/OrderCard.tsx 中,通过 JSDoc 标签将一个 island 标记为可生成组件:
/** @generative Shows one order with its status */
export default function OrderCard(props: { id: string; item: string; status: string; eta?: string }) {
return (
<article class="order-card">
<h3>Order {props.id}</h3>
<p>{props.item}</p>
<p><strong>{props.status}</strong>{props.eta ? `, arriving ${props.eta}` : ''}</p>
</article>
);
}
在 @Agent 中设置 ui: ['OrderCard'],允许 Agent 使用这个组件;再为工具添加一个 ctx: ToolContext 参数,就能在工具中展示它:
ctx.ui('OrderCard', { ...order });
现在,询问“订单 A-1001 到哪儿了?”,聊天中就会出现一张真正的订单卡片。props 会按照组件的类型进行校验,模型还会获得一个 show_order_card 工具,从而可以主动展示卡片。
离线模型带有一些随机性,玩起来有趣,却不够可预测,不适合用于测试。测试时,可以精确编排模型每一轮的行为:
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { AmoxiApplication, Config } from 'amoxi';
import { AiServices, ScriptedChatModel } from 'amoxi/ai';
import { applicationModule } from '../src/generated/amoxi.generated';
test('the support agent looks up an order before answering', async () => {
const model = new ScriptedChatModel([
{ toolCalls: [{ name: 'lookup_order', input: { orderId: 'A-1001' } }] },
{ text: 'Your backpack arrives on October 22.' },
]);
const app = await AmoxiApplication.run(applicationModule, {
config: Config.of({ server: { port: 0 } }),
overrides: [['ChatModel', model]],
silent: true,
shutdownHook: false,
});
try {
const platform = AiServices.of(app.context).platform();
const started = await platform.startRun({ agent: 'support-agent', input: 'Where is my backpack? A-1001', owner: 'me' });
let run = await platform.getRun(started.id);
while (run && !['completed', 'failed'].includes(run.status)) {
await new Promise((r) => setTimeout(r, 20));
run = await platform.getRun(started.id);
}
assert.equal(run?.output, 'Your backpack arrives on October 22.');
assert.match(JSON.stringify(model.requests[1].messages.at(-1)), /shipped/);
} finally {
await app.close();
}
});
这个按脚本运行的模型会调用真正的工具,因此测试能在不到一秒的时间内,检查你的工具代码、各部分的连接关系和整个循环,而且不需要访问网络。使用 npm test 运行测试。
与依赖注入一样,Agent 的连接配置也会在构建时检查:工具类不是 bean、两个工具重名、引用了未知的 UI 组件、工具缺少描述,或者 Agent 没有 instructions(),都会让 amoxi build 停止,并指出对应的文件和行号。
@Tool 把方法变成工具。工具的 schema 和校验器来自参数类型。
@Agent 对应一个包含 instructions() 和工具类列表的类。
每个 Agent 都有流式 HTTP API,并通过 agentChat() 在浏览器中工作。
approval: true 会暂停运行,直到人工做出决定。
带有 @generative 标记的 islands 让 Agent 能够展示组件。
ScriptedChatModel 让 Agent 测试既快速又精确。
本文中的运行只花了一秒。第 6 篇文章将讨论需要几分钟才能完成的运行、运行途中服务器重启的情况,以及如何让其他 AI Agent 通过 MCP 和 A2A 调用你的 Agent。
Amoxi 采用 Apache License 2.0 开源。代码地址:github.com/karawash-projects/amoxi。文档地址:karawash-projects.github.io/amoxi。
如果要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。