Tool-Using Agent 代码实现示例
如果说 最小 Agent 代码实现示例 解决的是:
一个最小 Agent 骨架,代码上最少要有什么
那这篇文档解决的是另一个更接近真实产品的问题:
当 Agent 真的开始调工具时,第一版代码到底应该怎么写?
很多人在理解 Tool-Using Agent 时,脑子里已经有了这些关键词:
goaltoolstateplannerloopobservation
但一落到代码层,还是会卡住:
state该记到什么粒度tool schema怎么定义才不容易失控- planner 输出应该长什么样
- 工具失败以后,Agent 是重试、换工具,还是结束
- 日志到底要记哪些字段,后面才好排查
所以这篇文档的定位,不是做一个“万能 Agent”,也不是讲大而全框架,而是提供一份:
比最小骨架更接近真实产品,但仍然足够小、足够容易照着写的 Tool-Using Agent 示例。
这里继续用 TypeScript / Node.js 风格来写伪代码,因为它改造成真实项目代码会比较顺手。
1. 它适合什么场景
不是所有任务都需要 Tool-Using Agent。
如果一个任务只是:
- 纯知识问答
- 一次生成文案
- 不依赖外部系统
- 不需要多步观察后再决定
那你通常不需要上 Agent。
更适合放进 Tool-Using Agent 的,是这类任务:
- 需要读取外部实时数据
- 一次工具调用拿不到完整答案
- 下一步动作依赖上一步结果
- 要在多个工具之间做选择
- 需要保留过程状态,而不是只保留最终输出
比如下面这些都很典型:
- 客服运营助手:判断一个用户是否需要人工跟 进
- 内部支持助手:查订单状态,再判断是否要升级工单
- 数据调研助手:先搜资料,再读页面,再提炼结论
- 运维助手:先查服务状态,再读日志,再决定是否继续排查
这类系统的共同点不是“模型会不会回答”,而是:
系统是否能围绕目标,稳定地决定“下一步该调什么工具”。
2. 先定一个最小但真实的产品场景
为了让代码示例尽量贴近真实产品,我们假设做一个:
客户跟进判断助手
用户会输入:
帮我判断 user_123 最近是否需要重点跟进。
这个 Agent 可能会做几件事:
- 查询用户近 7 天活跃情况
- 查询最近订单或付费状态
- 查询最近工单或投诉记录
- 基于这些结果给出判断和建议
这个场景适合作为第一版 Tool-Using Agent,因为它同时具备:
- 多个工具
- 多步决策
- 结果依赖前一步观察
- 最终输出不是工具原始结果,而是经过整合的建议
3. 最小架构应该长什么样
先不要急着写复杂流程,第一版最小架 构建议只拆成 6 层:
Input LayerPlanner / Decision LayerTool RegistryTool ExecutorState StoreFinal Synthesizer
它们的关系可以先理解成:
User Goal
-> Planner 决定下一步
-> 执行某个 Tool
-> 观察结果
-> 更新 State
-> 再决定下一步
-> 满足条件后输出最终结论
如果再压缩一点,本质上就是一条循环:
Decide -> Act -> Observe -> Update -> Decide
第一版最重要的,不是 planner 有多聪明,而是:
这个循环能不能稳定跑完,并且每一步都可解释。
4. State 设计:第一版到底该存什么
很多人做第一版时,state 容易走两个极端:
- 太小:只存用户输入,后面调试时完全看不出 Agent 为什么这么做
- 太大:什么都往里塞,最后谁都不敢改
更稳的做法是先围绕这 5 类信息设计:
- 当前目标
- 当前进度
- 已有证据
- 已执行动作
- 结束条件
一个比较实用的第一版 state 可以长这样:
type AgentState = {
sessionId: string;
userGoal: string;
currentStep: number;
maxSteps: number;
status: "running" | "needs_input" | "completed" | "failed";
facts: Record<string, unknown>;
evidence: EvidenceItem[];
toolHistory: ToolCallRecord[];
pendingQuestions: string[];
finalAnswer?: string;
lastError?: string;
};
type EvidenceItem = {
source: string;
summary: string;
confidence: number;
};
type ToolCallRecord = {
step: number;
toolName: string;
args: Record<string, unknown>;
resultSummary: string;
success: boolean;
durationMs: number;
};
这套结构看起来已经比最小 Agent 丰富一些,但仍然是可控的。
每个字段的职责要尽量单一:
facts:存结构化中间结果,比如lastActiveDaysAgo、hasOpenTicketevidence:存适合最后生成结论时引用的证据摘要toolHistory:存运行轨迹,方便调试和评估pendingQuestions:记录当前还缺什么信息status:明确循环为什么停
第一版不要急着做:
- 超复杂 memory
- 向量长期记忆
- 多层 state machine
- 跨会话共享上下文
这些都不是第一版最容易出成果的地方。
5. Tool Schema 设计:越清楚越稳
Tool-Using Agent 的失败,很多时候不是模型不够强,而是工具定义太含糊。
一个糟糕的工具定义通常长这样:
- 名字太泛
- 参数太随意
- 返回结构不稳定
- 一次工具做了太多事
比如:
query_business_data(query: string)
这个定义的问题是,Agent 根本不知道它到底是查用户、查订单,还是查工单。后面 prompt 一复杂,就很容易选错。
更好的方式是把工具拆成职责明确的最小单元:
type ToolDefinition<TArgs = Record<string, unknown>, TResult = unknown> = {
name: string;
description: string;
inputSchema: {
type: "object";
properties: Record<string, unknown>;
required?: string[];
};
run: (args: TArgs) => Promise<TResult>;
};
示例:
type GetUserActivityArgs = {
userId: string;
days: number;
};
type GetUserActivityResult = {
userId: string;
loginCount: number;
lastActiveAt: string | null;
activeDays: number;
};
const getUserActivityTool: ToolDefinition<
GetUserActivityArgs,
GetUserActivityResult
> = {
name: "get_user_activity",
description: "Get recent user activity summary for a given user",
inputSchema: {
type: "object",
properties: {
userId: { type: "string", description: "Target user id" },
days: { type: "number", description: "Lookback window in days" },
},
required: ["userId", "days"],
},
async run(args) {
return {
userId: args.userId,
loginCount: 2,
lastActiveAt: "2026-04-27T10:20:00Z",
activeDays: 1,
};
},
};
同理,你还可以再定义:
get_user_ordersget_open_ticketssend_followup_task
第一版工具设计有几个经验非常重要:
- 工具名必须表达动作和对象
- schema 要让模型看得懂参数含义
- 返回结构要稳定,不要一会儿 string、一会儿 object
- 工具一次只做一类事
- 能结构化返回,就不要只返回大段自然语言
6. Planner / Decision 输出结构怎么设计
很多人在这里一上来就想做一个“会自动规划一整条任务链”的 planner。
这不一定错,但第一版更实用的方式通常是:
每轮只决定下一步。
这里不要求模型先产出 7 步总计划,而是每一轮只回答一个问题:
下一步是继续调哪个工具,还是已经可以结束?
一个足够实用的输出结构可以这样定义:
type AgentDecision =
| {
type: "tool";
toolName: string;
args: Record<string, unknown>;
reason: string;
expectedOutcome: string;
}
| {
type: "ask_user";
question: string;
reason: string;
}
| {
type: "finish";
reason: string;
answerDraft: string;
};
这个结构的好处是很明显的:
tool:做动作ask_user:缺关键参数时停下来问finish:明确结束并给出答案草稿
其中 reason 很重要。
因为在真实产品里,planner 不只是为了“跑通”,还要为了:
- 调试
- 回放
- 做 eval
- 做错误分析
如果没有 reason,后面看到一堆工具调用记录时,基本只能猜它为什么这么做。
7. 一个较完整的 TypeScript 伪代码实现
下面这个示例故意写得比最小 Agent 更接近真实产品,但仍然保持:
- 不引复杂框架
- 不引 workflow engine
- 不依赖外部 Agent SDK
- 所有关键概念都能一眼看到
type JsonObject = Record<string, unknown>;
type AgentState = {
sessionId: string;
userGoal: string;
currentStep: number;
maxSteps: number;
status: "running" | "needs_input" | "completed" | "failed";
facts: JsonObject;
evidence: Array<{
source: string;
summary: string;
confidence: number;
}>;
toolHistory: Array<{
step: number;
toolName: string;
args: JsonObject;
resultSummary: string;
success: boolean;
durationMs: number;
}>;
pendingQuestions: string[];
finalAnswer?: string;
lastError?: string;
};
type AgentDecision =
| {
type: "tool";
toolName: string;
args: JsonObject;
reason: string;
expectedOutcome: string;
}
| {
type: "ask_user";
question: string;
reason: string;
}
| {
type: "finish";
reason: string;
answerDraft: string;
};
type ToolDefinition<TArgs = JsonObject, TResult = unknown> = {
name: string;
description: string;
inputSchema: JsonObject;
run: (args: TArgs) => Promise<TResult>;
};
class ToolRegistry {
private tools = new Map<string, ToolDefinition>();
register(tool: ToolDefinition) {
this.tools.set(tool.name, tool);
}
get(toolName: string) {
return this.tools.get(toolName);
}
listForPrompt() {
return Array.from(this.tools.values()).map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
}
}
function createInitialState(userGoal: string): AgentState {
return {
sessionId: crypto.randomUUID(),
userGoal,
currentStep: 0,
maxSteps: 6,
status: "running",
facts: {},
evidence: [],
toolHistory: [],
pendingQuestions: [],
};
}
async function planNextAction(
state: AgentState,
toolsForPrompt: Array<{ name: string; description: string; inputSchema: JsonObject }>,
): Promise<AgentDecision> {
const plannerInput = {
userGoal: state.userGoal,
currentStep: state.currentStep,
maxSteps: state.maxSteps,
facts: state.facts,
evidence: state.evidence,
toolHistory: state.toolHistory,
availableTools: toolsForPrompt,
instruction:
"Choose exactly one next action. Prefer the smallest useful next step. Finish when evidence is enough.",
};
const raw = await fakeModelJsonCall(plannerInput);
return validateDecision(raw);
}
async function executeTool(
tool: ToolDefinition,
args: JsonObject,
): Promise<{ result: unknown; durationMs: number }> {
const startedAt = Date.now();
const result = await tool.run(args);
return {
result,
durationMs: Date.now() - startedAt,
};
}
function updateStateFromToolResult(
state: AgentState,
toolName: string,
args: JsonObject,
result: unknown,
durationMs: number,
): AgentState {
const nextState = structuredClone(state);
const resultSummary = summarizeToolResult(toolName, result);
nextState.toolHistory.push({
step: nextState.currentStep,
toolName,
args,
resultSummary,
success: true,
durationMs,
});
if (toolName === "get_user_activity") {
const data = result as {
loginCount: number;
activeDays: number;
lastActiveAt: string | null;
};
nextState.facts.userLoginCount = data.loginCount;
nextState.facts.userActiveDays = data.activeDays;
nextState.facts.lastActiveAt = data.lastActiveAt;
nextState.evidence.push({
source: toolName,
summary: `近 7 天仅活跃 ${data.activeDays} 天,登录 ${data.loginCount} 次`,
confidence: 0.9,
});
}
if (toolName === "get_user_orders") {
const data = result as {
paidOrderCount: number;
lastPaidAt: string | null;
};
nextState.facts.paidOrderCount = data.paidOrderCount;
nextState.facts.lastPaidAt = data.lastPaidAt;
nextState.evidence.push({
source: toolName,
summary: `最近付费订单数 ${data.paidOrderCount}`,
confidence: 0.85,
});
}
if (toolName === "get_open_tickets") {
const data = result as {
openTicketCount: number;
urgentTicketCount: number;
};
nextState.facts.openTicketCount = data.openTicketCount;
nextState.facts.urgentTicketCount = data.urgentTicketCount;
nextState.evidence.push({
source: toolName,
summary: `当前未关闭工单 ${data.openTicketCount} 个,其中紧急 ${data.urgentTicketCount} 个`,
confidence: 0.95,
});
}
return nextState;
}
function markToolFailure(
state: AgentState,
toolName: string,
args: JsonObject,
error: unknown,
durationMs: number,
): AgentState {
const nextState = structuredClone(state);
const message = error instanceof Error ? error.message : String(error);
nextState.toolHistory.push({
step: nextState.currentStep,
toolName,
args,
resultSummary: `ERROR: ${message}`,
success: false,
durationMs,
});
nextState.lastError = message;
return nextState;
}
function shouldStop(state: AgentState): boolean {
return (
state.status === "completed" ||
state.status === "failed" ||
state.status === "needs_input" ||
state.currentStep >= state.maxSteps
);
}
function buildFinalAnswer(state: AgentState): string {
const evidenceLines = state.evidence.map((item) => `- ${item.summary}`).join("\n");
return [
`判断结果:该用户${recommendFollowup(state) ? "建议重点跟进" : "暂不需要重点跟进"}`,
"",
"主要依据:",
evidenceLines,
"",
`建议动作:${recommendFollowup(state) ? "安排人工回访,并查看工单上下文" : "继续自动观察 3 到 7 天"}`,
].join("\n");
}
async function runAgent(userGoal: string, registry: ToolRegistry) {
let state = createInitialState(userGoal);
while (!shouldStop(state)) {
state.currentStep += 1;
logEvent("agent.step.started", {
sessionId: state.sessionId,
step: state.currentStep,
goal: state.userGoal,
});
let decision: AgentDecision;
try {
decision = await planNextAction(state, registry.listForPrompt());
} catch (error) {
state.status = "failed";
state.lastError = error instanceof Error ? error.message : String(error);
logEvent("agent.plan.failed", {
sessionId: state.sessionId,
step: state.currentStep,
error: state.lastError,
});
break;
}
logEvent("agent.decision.made", {
sessionId: state.sessionId,
step: state.currentStep,
decision,
});
if (decision.type === "ask_user") {
state.pendingQuestions.push(decision.question);
state.status = "needs_input";
break;
}
if (decision.type === "finish") {
state.finalAnswer = decision.answerDraft || buildFinalAnswer(state);
state.status = "completed";
break;
}
const tool = registry.get(decision.toolName);
if (!tool) {
state.status = "failed";
state.lastError = `Unknown tool: ${decision.toolName}`;
break;
}
const startedAt = Date.now();
try {
const { result, durationMs } = await executeTool(tool, decision.args);
state = updateStateFromToolResult(
state,
tool.name,
decision.args,
result,
durationMs,
);
logEvent("agent.tool.succeeded", {
sessionId: state.sessionId,
step: state.currentStep,
toolName: tool.name,
durationMs,
});
} catch (error) {
const durationMs = Date.now() - startedAt;
state = markToolFailure(state, tool.name, decision.args, error, durationMs);
logEvent("agent.tool.failed", {
sessionId: state.sessionId,
step: state.currentStep,
toolName: tool.name,
durationMs,
error: state.lastError,
});
if (countRecentFailures(state, tool.name) >= 2) {
state.status = "failed";
}
}
}
if (state.currentStep >= state.maxSteps && state.status === "running") {
state.finalAnswer = buildFinalAnswer(state);
state.status = "completed";
}
return state;
}
这个版本虽然还是伪代码,但已经体现出真实产品中非常关键的几件事:
- planner 和 tool execution 是分开的
- tool 调用结果会回写到结构化
facts - 所有调用都有
toolHistory - 每一步都能打日志
- 有明确的停止条件
- 工具失败时不会直接把整个系统写死
8. 这个示例里应该优先模仿的几个点
8.1 planner 不直接操作工具
planNextAction 只负责“决定”,不负责“执行”。
这是个很重要的 边界,因为一旦你把决定和执行揉在一起,后面会很难:
- 单独测试 planner
- 做离线回放
- 评估工具选择质量
8.2 state 更新函数单独抽出来
updateStateFromToolResult 看起来有点啰嗦,但它能明显提升可维护性。
这样做以后,系统的关键逻辑会变得清楚:
- planner 决定下一步
- executor 执行工具
- updater 解释结果并写回 state
这比“工具结果回来以后,循环里现场拼逻辑”稳很多。
8.3 final answer 不是工具输出拼接
第一版经常会写成:
- 活跃结果一段
- 订单结果一段
- 工单结果一段
- 直接拼给用户
这通常不够像产品。
更好的做法是:
让最终输出围绕用户目标给出判断,再附依据。
也就是从“原始查询结果”升级成“任务结论”。
9. 错误处理:第一版至少要覆盖哪些问题
只要系统开始调工具,错误处理就不再是可选项。
第一版至少要覆盖下面几类:
9.1 工具不存在
比如 planner 输出了一个没有注册的 toolName。
这通常说明:
- planner prompt 有问题
- tool list 和 prompt 不一致
- 模型在幻觉工具名
最小处理方式:
- 记录错误日志
- 将本轮状态标为失败
- 把未知工具名保留下来,方便排查
9.2 参数格式不合法
比如 schema 要求 days 是 number,但模型给了 string。
第一版不要偷懒,至少要有:
- decision 校验
- tool args 校验
- 不合法时拒绝执行
否则你后面会遇到大量“工具调用成功但结果完全不对”的隐性错误。
9.3 工具超时或下游接口失败
这在真实产品里非常常见。
第一版可用的最小策略 通常是:
- 超时后记一次失败
- 对同一工具允许 1 次有限重试
- 连续失败达到阈值就结束任务
不要一开始就做无限重试。
因为对 Agent 来说,无限重试往往只是在放大延迟和成本。
9.4 结果为空
有些错误不是报错,而是“查到了空数据”。
这时不要简单当成功或失败,而要看业务语义。
比如:
- 没有工单,可能是正常信号
- 没有用户 ID,可能应该转为
ask_user - 没有订单数据,可能只是不足以下结论
所以你需要区分:
technical failurebusiness empty result
这两类不要混在一起。
10. 日志:第一版最少要记哪些字段
很多 Agent 第一版都能“跑”,但一出问题就查不动。原因通常不是模型本身,而是:
没有把运行过程记下来。
建议第一版最少打这些日志字段:
sessionIdstepuserGoaldecision.typedecision.toolNametool.argstool.durationMstool.successerror.messagestate.status
一个最简单的日志函数可以是:
function logEvent(eventName: string, payload: Record<string, unknown>) {
console.log(
JSON.stringify({
timestamp: new Date().toISOString(),
eventName,
...payload,
}),
);
}
真实产品里你当然可能会接:
- OpenTelemetry
- Datadog
- ELK
- 云日志平台
但第一版先把字段打完整,价值已经很大。
你后面做这些事情都会轻松很多:
- 回放某次失败任务
- 看 planner 是否频繁选错工具
- 统计平均步数
- 统计哪个工具最常超时
11. 最小 Eval 从哪里切入
很多团队一说到 eval,就会马上想到一整套复杂评测平台。
其实第一版完全没必要。
对 Tool-Using Agent 来说,可以先从 3 个最小问题开始:
11.1 工具选对了吗
准备一小批样本,比如:
- 查活跃度的请求
- 查订单状态的请求
- 查工单升级的请求
然后看 planner 选出来的工具是不是基本合理。
这叫:
tool selection eval
11.2 参数提对了吗
即使工具选对了,参数也可能错。
比如:
userId提错days默认错- 漏了必填字段
这叫:
argument quality eval
11.3 最终有没有完成任务
最终输出是不是回答了用户真正的问题,而不是只把工具结果复述一遍。
这叫:
task completion eval
第一版甚至可以只用一个很朴素的数据结构:
type EvalCase = {
input: string;
expectedTools: string[];
expectedMustContain: string[];
};
例如:
const cases: EvalCase[] = [
{
input: "帮我判断 user_123 最近是否需要重点跟进",
expectedTools: ["get_user_activity", "get_open_tickets"],
expectedMustContain: ["重点跟进", "工单"],
},
];
第一版先做到:
- 有 10 到 20 条固定样本
- 能跑出工具轨迹
- 能检查最终输出是否包含关键结论
这已经足够让你开始迭代了。