跳到主要内容

Tool-Using Agent 代码实现示例

如果说 最小 Agent 代码实现示例 解决的是:

一个最小 Agent 骨架,代码上最少要有什么

那这篇文档解决的是另一个更接近真实产品的问题:

当 Agent 真的开始调工具时,第一版代码到底应该怎么写?

很多人在理解 Tool-Using Agent 时,脑子里已经有了这些关键词:

  • goal
  • tool
  • state
  • planner
  • loop
  • observation

但一落到代码层,还是会卡住:

  • state 该记到什么粒度
  • tool schema 怎么定义才不容易失控
  • planner 输出应该长什么样
  • 工具失败以后,Agent 是重试、换工具,还是结束
  • 日志到底要记哪些字段,后面才好排查

所以这篇文档的定位,不是做一个“万能 Agent”,也不是讲大而全框架,而是给你一个:

比最小骨架更接近真实产品,但仍然足够小、足够容易照着写的 Tool-Using Agent 示例。

这里我继续用 TypeScript / Node.js 风格来写伪代码,因为它很适合作为你后续改造成真实项目代码的起点。

1. 它适合什么场景

不是所有任务都需要 Tool-Using Agent

如果一个任务只是:

  • 纯知识问答
  • 一次生成文案
  • 不依赖外部系统
  • 不需要多步观察后再决定

那你通常不需要上 Agent。

更适合 Tool-Using Agent 的,是这类任务:

  • 需要读取外部实时数据
  • 一次工具调用拿不到完整答案
  • 下一步动作依赖上一步结果
  • 要在多个工具之间做选择
  • 需要保留过程状态,而不是只保留最终输出

比如下面这些都很典型:

  • 客服运营助手:判断一个用户是否需要人工跟进
  • 内部支持助手:查订单状态,再判断是否要升级工单
  • 数据调研助手:先搜资料,再读页面,再提炼结论
  • 运维助手:先查服务状态,再读日志,再决定是否继续排查

你会发现,这类系统的共同点不是“模型会不会回答”,而是:

系统是否能围绕目标,稳定地决定“下一步该调什么工具”。

2. 先定一个最小但真实的产品场景

为了让代码示例尽量贴近真实产品,我们假设做一个:

客户跟进判断助手

用户会输入:

帮我判断 user_123 最近是否需要重点跟进。

这个 Agent 可能会做几件事:

  1. 查询用户近 7 天活跃情况
  2. 查询最近订单或付费状态
  3. 查询最近工单或投诉记录
  4. 基于这些结果给出判断和建议

这个场景很适合作为第一版 Tool-Using Agent,因为它同时具备:

  • 多个工具
  • 多步决策
  • 结果依赖前一步观察
  • 最终输出不是工具原始结果,而是经过整合的建议

3. 最小架构应该长什么样

先不要急着写复杂流程,第一版最小架构建议只拆成 6 层:

  1. Input Layer
  2. Planner / Decision Layer
  3. Tool Registry
  4. Tool Executor
  5. State Store
  6. Final 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:存结构化中间结果,比如 lastActiveDaysAgohasOpenTicket
  • evidence:存适合最后生成结论时引用的证据摘要
  • 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_orders
  • get_open_tickets
  • send_followup_task

第一版工具设计有几个经验非常重要:

  1. 工具名必须表达动作和对象
  2. schema 要让模型看得懂参数含义
  3. 返回结构要稳定,不要一会儿 string、一会儿 object
  4. 工具一次只做一类事
  5. 能结构化返回,就不要只返回大段自然语言

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 failure
  • business empty result

这两类不要混在一起。

10. 日志:第一版最少要记哪些字段

很多 Agent 第一版都能“跑”,但一出问题就查不动。原因通常不是模型本身,而是:

没有把运行过程记下来。

建议第一版最少打这些日志字段:

  • sessionId
  • step
  • userGoal
  • decision.type
  • decision.toolName
  • tool.args
  • tool.durationMs
  • tool.success
  • error.message
  • state.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 条固定样本
  • 能跑出工具轨迹
  • 能检查最终输出是否包含关键结论

这已经足够让你开始迭代了。

12. 第一版最容易犯的错

这是最值得提前避开的部分。

12.1 工具设计过于万能

很多人第一版喜欢做一个“大总线工具”:

  • query_db
  • call_backend
  • do_anything

短期看很快,长期几乎一定失控。

因为模型很难稳定理解“这个工具什么时候该用、参数怎么传、结果怎么解释”。

12.2 planner 一上来就做总规划

第一版最常见的误区之一,是强迫模型先输出完整计划,再严格执行。

但真实任务里,后一步通常依赖前一步观察结果。

所以更稳的起点通常是:

每轮只决定下一步。

12.3 不做结构化 state

如果你只是把工具返回的大段文本堆起来,后面会越来越难:

  • 判断证据是否够用
  • 去重
  • 生成稳定最终答案
  • 做 eval

所以哪怕第一版很小,也尽量把关键中间结果写进 facts

12.4 没有明确停止条件

这会直接导致:

  • 无限循环
  • 重复查同一个工具
  • 成本和延迟飙升

最小停止条件至少要有:

  • maxSteps
  • finish decision
  • 连续错误阈值
  • ask_user 中断

12.5 完全不记过程日志

这类系统如果没有日志,出了问题基本只能靠猜。

Tool-Using Agent 最需要的恰恰是:

过程可观察性。

12.6 把工具调用成功当成任务完成

很多第一版系统会默认:

  • 工具查到了东西
  • 所以任务完成了

但这两者不是一回事。

用户要的通常不是“我帮你查到了 3 个数据源”,而是:

  • 该不该跟进
  • 风险高不高
  • 下一步要做什么

所以最终一定要回到:

是否完成了用户目标。

13. 一个很实用的落地建议

如果你准备把这篇示例改成真实代码,我建议你第一版就控制在下面这个规模:

  • 2 到 4 个工具
  • 1 个简单 planner
  • 1 个循环执行器
  • 1 个结构化 state
  • 1 组固定 eval case
  • 1 套 JSON 日志

这已经足够支撑一个能演示、能调试、能迭代的产品雏形。

不要第一版就同时做:

  • 多 Agent 协作
  • 长期记忆
  • 动态工作流编排
  • 复杂权限系统
  • 十几个工具

这些不是不能做,而是太容易把“本来应该先学会的核心闭环”冲淡掉。

14. 你可以怎么理解这篇代码示例的价值

如果最小 Agent 骨架回答的是:

Agent 至少要有哪些部件

那这个 Tool-Using Agent 示例回答的是:

当系统开始真的调工具时,第一版工程结构应该怎么站稳。

你真正应该学到的,不只是那段 TypeScript 伪代码,而是背后的几个原则:

  • 工具边界要清楚
  • decision 输出要结构化
  • state 要能支撑观察和收束
  • 错误处理和日志第一版就要有
  • eval 不必大,但要尽早开始

当这些东西站稳以后,你再去加:

  • 更强的 planner
  • 更丰富的 tools
  • 更复杂的 memory
  • 更严格的评测

系统就会是“逐步长出来”的,而不是第一天就失控。