不引任何 Agent 框架,只用原生 JS 和一个
while循环,把 Agent 从"听起来很玄"变成"我知道它每一行在干嘛"。 全程 Node.js + 原生fetch,一个硅基流动的免费 API Key 就能复现。
本文目标:读完你应该能建立三层认知,并有一份能演示的代码支撑:
- 机制层 :讲清 Function Calling 的职责边界------模型只输出结构化调用意图(
tool_calls),执行权始终在你的代码手里;并说清tool_call_id为何是多工具回填时不可省略的配对凭证。 - 架构层:看穿 Agent 的本质是"Function Calling + 循环",理解它的自主性从何而来、又为何天然不可控。
- 工程层 :能列出让 Agent 可上线的关键护栏------
MAX_STEPS成本上限、工具容错、以客观信号(tool_calls是否为空)而非模型措辞判定收尾。
最终落到一份能跑、能改、能在面试时打开演示的最小实现。
写在前面:这篇接着 RAG 系列讲什么
RAG 系列讲的是同一件事:让模型学会"查资料"------检索、拼 prompt、照着资料答。但那始终是"人来分派":是我在代码里写死了"先检索、再回答"这个顺序。
真实需求往往更麻烦。用户问"帮我查下上个月销售额,顺便确认下 amount 这个字段含不含税"------这一句话里藏着两个任务,还有先后:得先搞清楚字段口径,才能写对 SQL。该调哪个能力、按什么顺序调,是问题本身决定的,没法提前写死。
Agent 就是来接这一层的:把"按什么顺序调哪个工具"这个决策,从我手里交给模型。而它的地基,是一个前端常常误会的机制------Function Calling。这篇就从那个误会讲起。
🍽️ 第 1 章:Function Calling 的最大误会------AI 并不"执行"
本章目标:先建立"Function Calling 是什么、解决什么问题"的基本概念,再拆掉"AI 帮我把函数跑了"这个最常见的误解,看清它真正做的那一件事。
先一句话说清它是什么
大模型本质是个文本生成器 :它能理解你的话、能写字,但没有手------查不了实时天气、读不了你的数据库、发不了任何请求。你问它"北京现在几度",它只能靠训练时记住的旧知识猜,给不了实时答案。
Function Calling 就是给这个"只会说话的大脑"外挂一双手的机制:
你预先告诉模型"我这儿有哪些函数可用"(比如
get_weather),模型在需要时不再自己硬答,而是回你一句"请帮我调用get_weather,参数填北京"。真正的调用由你的代码完成,结果再喂回给它组织成人话。
一句话:Function Calling 让模型从"只能凭记忆答"升级成"知道该借用哪个外部能力、并说清参数"。 这也是后面 Agent 能自主干活的地基。
但恰恰是"调用"这两个字,最容易让人理解偏------下面这个误会,我自己就踩过。
我一开始就理解错了
刚接触 Function Calling 时,我的心智模型是这样的:给模型注册一个 getWeather 函数,问它"北京天气怎么样",它就自己去调 getWeather('北京')、拿到结果、告诉我。
真上手才发现完全不是。模型拿不到我的代码、连不上我的数据库、发不了任何 HTTP 请求------它压根没有"执行"这个能力。如果你按"AI 会自己执行"去设计整条链路,是接不通的,因为那一步根本不存在。
打印一下就露馅了
把第一次调用的原始返回打印出来,真相就摆在眼前:
javascript
// fc-demo.js ------ 最小可跑:看清「模型只输出调用意图,不执行」
// tools 就是给模型的"工具说明书",第 2 章会完整拆解;这里先给一个最小版让代码能独立跑起来
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: '查询某个城市的实时天气',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: '城市名,如"北京"' } },
required: ['city'],
},
},
},
]
const res = await fetch('https://api.siliconflow.cn/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SILICONFLOW_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'deepseek-ai/DeepSeek-V3',
messages: [{ role: 'user', content: '北京天气怎么样' }],
tools, // 把上面的工具说明书一起发给模型
tool_choice: 'auto', // 让模型自己决定要不要调工具
}),
})
const data = await res.json()
// choices 是模型返回的候选回复数组,单轮对话只有一个,取 choices[0]
// choices[0] 里有两样关键东西:finish_reason(这轮为什么停)和 message(模型说的话)
console.log(JSON.stringify(data.choices[0], null, 2))
这段代码打印出的 choices[0],结构是这样(id 每次运行都不同,是一串随机字符,这里省略成 call_xxx;其余字段结构固定):
json
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xxx",
"type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" }
}
]
}
}
因为我们问的是"北京",arguments 里就是 {"city":"北京"}------模型从你的话里抽出了参数、填进了它决定要调的那个函数。换个城市问,这里就会跟着变。两个细节值得盯着看:
content是null。 模型一个字的人话都没说。finish_reason是tool_calls。 它不是正常聊完了,而是"卡"在这儿,等你去调工具。
模型到此为止只做了一件事:告诉我"该调哪个函数、参数填什么" 。真正的 getWeather('北京'),还得我自己的代码去跑。
点菜,不炒菜
我后来用一个比喻记住它的本质:AI 只负责点菜,不负责炒菜。
它看着菜单(你给的工具说明书),跟你说"我要一份 get_weather,参数是北京"------但它不进厨房。炒菜(真正执行函数)永远是你的代码干的。炒完把菜端回去(回填结果),它才尝一口、说出"北京今天晴,12℃"这句人话。
所以完整链路是一个五步闭环,AI 只碰第一步和最后一步:
text
① 我把「问题 + 工具说明书」发给 AI
② AI 回 tool_calls:「调 get_weather('北京')」 ← AI 点菜
③ 我自己的代码执行 getWeather('北京') ← 我炒菜
④ 把结果作为 role:'tool' 消息回填进 messages
⑤ 再调一次 AI,它拿结果生成「北京今天晴,12℃」 ← AI 尝味、说话
💡 一句话抓住本质 Function Calling 不是让 AI 执行代码,而是让 AI 输出"调哪个函数、参数是什么"的结构化意图。 执行永远是我的代码。它的作用是把"不确定的自然语言"翻译成"确定的函数调用"------相当于给只会生成文本的模型,外挂了一双手。
为什么这套设计反而是好事
你可能会想:那我干嘛不让 AI 直接写代码、我 eval 一下更省事?
因为那既不可控也不安全------AI 可能写出删库、死循环、越权访问的代码。而 Function Calling 是我预先定义好一批我信得过的函数 ,AI 只能在这个白名单里选一个、填参数,参数我还能再校验一层。安全边界完全捏在我手里。这不是限制,正是它能上生产的原因。
承上启下: 现在我们知道 AI 只是"点菜"。可它凭什么知道菜单上有哪些菜、每道菜要填什么参数?答案全在那份还没展开的"工具说明书"
tools里。下一章就把它拆开。
📋 第 2 章:tools 说明书 + tool_call_id 配对
本章目标:写清楚工具说明书长什么样,并理解回填结果时那个不起眼却致命的 tool_call_id。
工具说明书:写给 AI 看的 JSON Schema
模型不认识你的 getWeather 函数,它只认识一种约定格式的 JSON------描述"有个什么工具、干嘛用的、要哪些参数"。这就是 tools:
javascript
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: '查询某个城市的实时天气。当用户问天气、气温、下不下雨时调用。',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名,如"北京"' },
},
required: ['city'],
},
},
},
]
这里最该花心思的不是结构,是 description。模型就是靠读这两句描述,判断"用户这句话该不该调这个工具、调它填什么参数"。描述写得含糊,模型就调错工具------这一点在做多工具 Agent 时会被放大,第 4 章细讲。
🧩
required不是硬约束parameters里的required: ['city']只是给模型的提示 ,不是 API 帮你把关的校验。模型是概率生成的,完全可能漏填、填错类型。真正的校验得你在执行工具前自己做一层。别把required当保险。
执行完,怎么把结果还给 AI
模型回了 tool_calls,我执行完真正的函数,得把结果拼成一条 role: 'tool' 的消息,push 回 messages,再调一次模型。这里有个容易漏、一漏就报错的细节------tool_call_id:
javascript
// reply 就是第 1 章打印过的那个 message 对象,形如:
// {
// role: 'assistant',
// content: null,
// tool_calls: [
// {
// id: 'call_xxx',
// type: 'function',
// function: { name: 'get_weather', arguments: '{"city":"北京"}' }
// }
// ]
// }
const reply = data.choices[0].message
messages.push(reply) // ① 先把带 tool_calls 的 assistant 消息原样 push 回历史
// ② 逐个执行工具,每个结果配一条 tool 消息
for (const call of reply.tool_calls) {
const args = JSON.parse(call.function.arguments) // arguments 是字符串,必须 parse 成对象
const result = await runTool(call.function.name, args) // 我的代码真正执行,见下方说明
messages.push({
role: 'tool',
tool_call_id: call.id, // ★ 关键:把结果和"哪次调用"对上
content: JSON.stringify(result),
})
}
上面的 runTool 不是接口自带的,是我自己写的分发器 ------把模型点名的工具名,对应到我真正的那个函数上。它可以朴素到就是一个 switch:
javascript
// 我预先写好的真实函数(模型永远碰不到它,只能通过名字点名)
async function getWeather({ city }) {
// 这里才是真去查天气的地方:调气象 API、读库都行
return { city, weather: '晴', temp: 12 }
}
// 分发器:把"工具名 + 参数"路由到对应的真函数
async function runTool(name, args) {
switch (name) {
case 'get_weather':
return await getWeather(args)
default:
throw new Error(`未知工具:${name}`)
}
}
看清这层就懂了第 1 章那句话的分量:模型只是报出一个名字(get_weather)和一包参数,真正干活的 getWeather 全程在我手里,模型碰都碰不到。 这就是"点菜不炒菜"落到代码上的样子。
为什么非要 tool_call_id?
因为 messages 是个扁平数组 ,而 AI 一轮可能并行 要求调好几个工具------比如你问"北京和上海天气怎么样",它会一次性返回两个 tool_calls。你执行完 push 回去就是两条 role: 'tool' 消息,光靠顺序对不可靠。得有个"外键"把每条结果和它对应的那次调用绑死,这个外键就是 tool_call_id(等于当初那次调用的 call.id)。
记住三条约束,漏一条模型下一轮就报错或胡乱作答:
- 有几个
tool_calls就回填几条tool消息; - 每条的
tool_call_id精确对应; - 带
tool_calls的那条 assistant 消息,要先 push 回历史,再 push 各条结果。
🤔 arguments 为什么是字符串,还得 JSON.parse?(面试高频)
call.function.arguments 传回来是个 JSON 字符串 ('{"city":"北京"}'),不是对象。因为整个响应走 HTTP/JSON,而 arguments 的内容是模型动态生成的、结构不固定,API 索性把它定义成字符串字段原样透传。用之前必须 JSON.parse。
而且它是概率模型逐 token 生成 的,偶尔会冒出多余逗号、少个引号。所以生产里我用 try/catch 包住这个 parse,失败就当成一次"工具错误"回填给 AI 让它重试,而不是让 JSON.parse 抛异常把程序搞崩。这个"错误当结果回填"的思路,正是第 4 章 Agent 容错的核心。
承上启下: 到这里,"一次工具调用"的完整往返就通了:发问题+工具 → 收 tool_calls → 执行 → 带 id 回填 → 再问一次拿人话。但这还是写死的一来一回。真实任务需要"调完一个工具,看结果再决定下一步调什么"。怎么让它自己转起来?答案简单得让人意外------套个循环。
🔁 第 3 章:把 Function Calling 塞进 while 循环,就是 Agent
本章目标:看清 Agent 的真身:它不是什么新东西,就是"第 1、2 章那套往返"放进一个循环里反复跑。
一来一回的天花板
上两章的流程写死了"调一次工具 → 回填 → 再问一次 → 结束"。可"先查字段口径、再据此写 SQL"这种任务,第二步调什么,得看第一步查出来的结果。一来一回接不住这种依赖。
那怎么办?不结束就行了------把"调工具 → 回填"这段放进循环,每转一圈都让 AI 看着最新的中间结果,重新决定:是还要再调个工具,还是可以收尾给答案了。
这,就是 Agent。
Agent = Function Calling + while 循环。 每转一圈问一次 AI,AI 要么"还要调工具"、要么"给最终答案";要调就执行 + 回填、回到圈顶继续,给答案就跳出。调几圈、什么时候停,是 AI 自己根据中间结果定的。
先补一个小封装:callModel
第 1 章那次 fetch 我们要在循环里反复用,先把它抽成一个函数,省得每圈都写一遍:
javascript
// 问一次模型:把当前对话历史 + 工具说明书发过去,返回它这轮说的话(message 对象)
async function callModel(messages, tools) {
const res = await fetch('https://api.siliconflow.cn/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SILICONFLOW_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'deepseek-ai/DeepSeek-V3',
messages,
tools,
tool_choice: 'auto',
}),
})
const data = await res.json()
return data.choices[0].message // 就是第 1 章打印过的那个 message
}
最小 Agent 主循环
有了 callModel(第 1 章的 fetch)和 runTool(第 2 章的分发器),主循环本身就很短了。先用最朴素的 while (true) 把逻辑看清:
javascript
// agent.js ------ 最朴素的 Agent:让 AI 自主多步调工具
async function runAgent(userQuestion) {
const messages = [
{ role: 'system', content: '你是数据治理助手,可调用工具完成用户任务。' },
{ role: 'user', content: userQuestion },
]
while (true) {
const reply = await callModel(messages, tools) // 问一次 AI(第 1 章)
messages.push(reply) // 把它的话 push 回历史
// 出口:这轮 AI 不再要求调工具 → 它给的就是最终答案,跳出循环
if (!reply.tool_calls?.length) {
return reply.content
}
// 否则:逐个执行它点的工具,把结果回填,回到圈顶让 AI 继续决策
for (const call of reply.tool_calls) {
const args = JSON.parse(call.function.arguments) // 第 2 章:arguments 是字符串
const result = await runTool(call.function.name, args) // 第 2 章:分发到真函数
messages.push({
role: 'tool',
tool_call_id: call.id, // 第 2 章:结果和调用配对
content: JSON.stringify(result),
})
}
}
}
对照着看你会发现:循环体里的每一步全是第 1、2 章的旧知识 ------callModel 是那次 fetch,for 里执行工具、带 tool_call_id 回填就是第 2 章那套。真正新增的只有外面这层 while 和那句 if (!reply.tool_calls?.length) return。把"一来一回"套上循环,Agent 就有了雏形。
它跑起来是什么样
问它"帮我查上个月销售额,顺便确认 amount 含不含税",循环大致这样转:
text
第 1 圈:AI 说要调 search_metadata('amount') → 我执行、回填"amount 为不含税金额"
第 2 圈:AI 看到口径,说要调 run_sql('SELECT SUM(amount)...') → 我执行、回填查询结果
第 3 圈:AI 不再要工具,直接给出「上月销售额为 X 元(不含税)」 → 命中出口,返回
同一段代码,AI 自己决定了"先查口径、再写 SQL、最后总结"这个三步顺序。这份自主性,就是 Agent 比一来一回强的地方。
⚠️ 但这个 while(true) 有两颗雷 上面这版能跑,但只是能跑 。那句
while (true)把"调几圈"完全交给了 AI,而这份自主性藏着两个风险:① 万一它来回打转不收尾,while (true)就真的无限转下去,每一圈都是一次真金白银的 API 调用,账单直接失控;② 循环里runTool执行的是真实工具代码,一旦某个工具抛异常,会顺着循环冒泡上来,把整个runAgent搞崩,用户什么都拿不到。一个连 demo 都算不上、随时会烧钱或崩溃的循环,离"能用"还差得远。 这两颗雷怎么拆?正是下一章的事。
🛡️ 第 4 章:两道护栏------让自主不失控
本章目标:补齐上一章埋的两个坑:怎么防止无限烧钱、怎么让工具报错不搞崩循环。顺带讲清"循环到底靠什么判断该停"。
上一章那个朴素版能自主干活,但拖着两颗雷:无限烧钱、工具崩溃。这一章就给它加两道护栏堵上------Agent 的工程重点可以浓缩成一句话:给自由加边界。 两道护栏,缺一不可。
护栏一:MAX_STEPS 硬上限,管住成本
第一颗雷是那句 while (true):循环退出全指望 AI 自己想通,万一它陷进"生成 SQL → 报错 → 换个写法 → 又报错"的重试怪圈,就会一圈圈无限烧下去。改法很直接------把 while (true) 换成一个有次数上限的 for,给总步数焊一个硬顶:
javascript
const MAX_STEPS = 5 // 最多让 AI 编排 5 步
// 把上一章的 while (true) 换成这个 for,循环体(问 AI → 执行工具 → 回填)原样不动
for (let step = 1; step <= MAX_STEPS; step++) {
// ...和第 3 章一样:callModel → 判断出口 → 执行工具回填
}
// 关键区别:转满 MAX_STEPS 还没命中出口,就兜底返回,绝不无限转
return '任务较复杂,建议拆分后再问。'
和第 3 章唯一的差别,就是循环头从 while (true) 变成了 for (step <= MAX_STEPS),外加最后一行兜底。为什么是 5? 这是成本和能力的权衡值。大多数数据治理问题 2-3 步就够(查口径 → 写 SQL → 收尾),设 5 留了余量又不至于失控。真遇到更复杂的,兜底会提示用户"拆分后再问",而不是让它无上限烧下去。要更聪明可以按任务复杂度动态调,但硬上限这道底线必须有。
护栏二:工具 try/catch,把"报错"变成"结果"
第二颗雷在第 3 章循环体那两行裸调上------JSON.parse(...) 和 runTool(...) 都可能抛异常(参数不是合法 JSON、工具内部查库失败......),一旦抛出来就冒泡上去把整个 runAgent 搞崩。做法是把这两行包进一个带 try/catch 的函数,失败了不抛异常,而是把错误信息当成"这次工具的返回结果"回填给 AI:
javascript
// 把第 3 章循环里裸调的 parse + runTool 包一层容错
async function safeRunTool(call) {
try {
const args = JSON.parse(call.function.arguments)
const result = await runTool(call.function.name, args)
return JSON.stringify(result)
} catch (err) {
// 关键:不 throw,把错误当"工具结果"回填
return `工具执行出错:${err.message}`
}
}
有了它,第 3 章循环体里那两行裸调就简化成一句 const result = await safeRunTool(call)。这一下把"崩溃"降级成了"一条 AI 能读到的反馈":AI 下一圈看到"这一步失败了,原因是 XXX",就能自己决定换个参数重试、还是如实告诉用户"这个查不了"。体验比直接抛 500 好太多。
两道护栏是配合的
有人会问:把错误回填给 AI,它会不会陷入"失败 → 重试 → 又失败"的死循环?
有可能------而这正是两道护栏要配合的原因: 容错(护栏二)让单次失败不崩,步数上限(护栏一)防止失败被无限重试。就算 AI 犯轴反复重试,最多转 5 圈也会被 MAX_STEPS 强制收尾。单靠哪一道都不够,合起来才严丝合缝。
顺带说清:循环凭什么判断"该停了"
回头看第 3 章那行出口条件,它其实是个容易做错的关键决策:
javascript
if (!reply.tool_calls?.length) {
return reply.content // 这轮不要工具了,给的就是最终答案
}
一个自然的想法是"让 AI 自己说'我做完了'来结束"。别这么干。 那是自然语言,不可靠------AI 可能话里说"已完成"其实没调够工具,也可能默默给了答案却没吭声。
而"这轮 reply 里有没有 tool_calls"是一个结构化的客观信号 :有,就是它还想调工具;没有,就是它不再需要工具、给的是最终回答。用客观信号判出口,比解析 AI 的措辞稳得多。 这是 Agent 循环里最该守住的一条原则。
🤔 AI 怎么知道该调哪个工具?调错了怎么办?
全靠工具的 description 写得准不准------它相当于给 AI 的路由规则。
我实测踩过这个坑:description 写模糊时,AI 会调错工具,比如把"解读一组数字"的活派给了 search_metadata(查元数据的)。改法不是改代码,是把工具描述重写清楚------什么场景该用它、什么场景不该用,边界写明白。
所以有个反直觉的结论:Agent 调优的大头往往不在代码,在这些自然语言写的工具说明书上。 步数上限管成本、出口条件管收尾、工具容错管健壮、而工具描述管的是"路由准不准"------四样一起,才算把 Agent 的护栏配齐。
承上启下: 护栏齐了,Agent 就能稳定地自主编排多步任务。最后退一步:这套东西到底什么时候值得上,什么时候纯属自找麻烦?
🎯 第 5 章:什么时候该上 Agent,什么时候别碰
本章目标:建立"该不该用 Agent"的判断力------这比"会用"更能体现工程成熟度。
Agent 多了"决策能力",但决策能力是双刃剑:步骤越多越容易跑偏、越费钱、越慢。所以不是所有问题都该套 Agent。一个朴素的判断标准:
| 问题类型 | 例子 | 该用什么 |
|---|---|---|
| 一步就能答 | "amount 含税吗" | 直接单次问答 / 单次检索,别套 Agent |
| 多步、但顺序固定 | "检索资料再回答" | 写死流程的 RAG 就够,也不用 Agent |
| 多步、且顺序由中间结果决定 | "怎么查销售额?顺便确认 amount 含不含税" | ✅ 这才是 Agent 的主场 |
核心区别在最后一行的"顺序由中间结果决定"------第二步调什么取决于第一步查出来什么,没法提前写死。只有这种情况,才值得把编排权交给 AI、并为此付出多轮 API 调用的成本。
🤔 那我把问题直接发给模型,它自己会判断该不该用 Agent 吗?
这里要把两层判断分开,它们发生在不同时间、由不同角色做:
第一层:要不要把这套逻辑包成 Agent(while 循环)------这是你写代码时的架构决策,模型参与不了。 "包不包循环"是你代码的结构,请求还没来你就已经定了:这个接口走单次问答,还是走 runAgent 那个循环。模型是被你的代码"装进"某个结构里跑的,它没法把自己从循环里摘出去。
第二层:进了循环之后,这一轮要不要调工具------这个才是模型自己判断的(靠 tool_choice: 'auto')。 这就是"直接发 question,它能不能自己判断"的答案:能,但只判断第二层。 比如你把什么都塞进 runAgent:
- 问"amount 含税吗"(简单)→ 模型第一圈就发现"直接答就行,不用调工具"→ 返回 content、
tool_calls为空 → 循环转一圈就退,效果约等于单次问答; - 问"查销售额并确认口径"(复杂)→ 第一圈调
search_metadata、第二圈调run_sql......多转几圈。
所以全走 Agent,模型确实能靠"这轮要不要 tool_calls"把简单问题短路掉,看起来像"它自己判断了该不该用 Agent"。
那第 5 章为什么还要你手动判断? 因为"能短路"不等于"没成本"。哪怕简单问题只转一圈,你也已经付出:① 每次都把整套 tools 说明书塞进 prompt,token 更多、更贵、首字更慢;② 多一层不确定性,模型偶尔给简单问题也硬点个工具,弄巧成拙;③ 循环、护栏、回填这套复杂度,你为一个本可一行 chat() 搞定的问题白背着。
一句话:模型能判断"这一轮要不要调工具",但判断不了"该不该把我包进 Agent 循环"------后者是你的活,且该在写代码时就拍板。
🛠️ 那"架构判断"具体怎么落地?是配置开关还是别的工作流?
都不是。它不是"创建对话时设个开关",也不是"配某个工作流平台",就是你后端代码里一个普通的 if 分流------决定这个请求调哪个函数。
你手里其实有两个函数------一个是单次问答,一个就是本文第 3 章写的 runAgent:
chat(question)------ 单次问答,请求里根本不带tools字段 ,模型无工具可调、只能直接答(就是把第 1 章那次 fetch 去掉tools);runAgent(question)------ 第 3 章那个带循环的 Agent,带了tools,模型能多步调工具。
所谓"架构判断",就是请求进来时用代码决定调哪个:
javascript
async function handleRequest(question, scene) {
if (scene === 'simple_qa') {
return await chat(question) // 简单场景:不给工具,走单次问答
} else {
return await runAgent(question) // 复杂场景:走 Agent 循环
}
}
关键:chat 和 runAgent 的差别不是"设定模型能不能用工具"这种开关,而是你压根有没有在请求里带 tools。不带,模型想调也无从调起;带了,它才有得选。
而上面那个 scene 从哪来,就是这个判断真正落地的地方,从粗到细三种:
- 按入口分(最常见) :不同功能天生是不同架构------"快速问答"输入框调
chat,"数据分析助手"功能调runAgent。用户点了哪个功能就决定了走哪条路,判断在你设计功能时就做完了,运行时都不用再判。 - 按规则分:用关键词/长度等简单规则预判,比如含"对比""并且""顺便"等多任务信号就走 Agent。糙但零成本。
- 让一个模型先分类(进阶) :先用一次便宜调用问"这问题需要多步工具吗?yes/no"再分流。这才是真正"让模型帮你判断",但它是你主动加的一道前置分类、要多花一次调用,不是 Agent 自己会做的事。
所以不需要配什么工作流。最常见就是第 1 种:不同功能入口走不同函数,一个
if的事,连运行时判断都省了。
知道什么时候不该用,比会用更重要。 一步能答的问题硬套 Agent,只会更慢更贵还更容易出错。
🏔️ 一句话收尾 Agent 的工程量,八成花在"给自主套护栏"上,而不是"让它能自主"上。 让它自主只要一个while循环;让它自主得可控,才需要步数上限、客观出口、工具容错、精准的工具描述这一整套。前者是 demo,后者才是能上线的 Agent。
🎁 结语:Agent 没有魔法,它只是一个套了循环的 Function Calling
💎 只记一句话也够 AI 点菜,你炒菜;把这套点菜-炒菜塞进循环,就是 Agent;给循环加上刹车(步数上限)和安全带(工具容错),才是能上线的 Agent。
所以从头到尾,没有一个新魔法:Function Calling 是一次 fetch,Agent 是套了个 while,护栏是一句 for 上限加一个 try/catch。你早就会这些,只是没人把它们这样拼给你看过。
而把这篇接到 RAG 系列后面,会显出一条更大的主线:RAG 让模型学会"查资料",顺序是你写死的 ;Agent 在同一批能力之上,只多给了一层"让 AI 自己决定按什么顺序调"的编排。地基没变,变的是谁在做决策------从人分派,到 AI 自主。
🚀 最后的最后 代码总共几十行,别只读,自己敲一遍跑起来。 当你亲眼看到 Agent 第一圈查口径、第二圈据此写 SQL、第三圈自己收尾时,"自主编排"这四个字才真正长进你脑子里------面试时你讲的就不是概念,是你跑通过的东西。
💡 原创声明本文首发于我的个人博客 rjy92.github.io。如需转载请注明出处。 如果这篇文章帮到了你,欢迎在以下平台关注、交流: