前端也能搞懂 Agent:从 Function Calling 到自主编排

不引任何 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":"北京"}------模型从你的话里抽出了参数、填进了它决定要调的那个函数。换个城市问,这里就会跟着变。两个细节值得盯着看:

  • contentnull 模型一个字的人话都没说。
  • finish_reasontool_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)。

记住三条约束,漏一条模型下一轮就报错或胡乱作答:

  1. 有几个 tool_calls 就回填几条 tool 消息;
  2. 每条的 tool_call_id 精确对应;
  3. 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 循环
  }
}

关键:chatrunAgent 的差别不是"设定模型能不能用工具"这种开关,而是你压根有没有在请求里带 tools。不带,模型想调也无从调起;带了,它才有得选。

而上面那个 scene 从哪来,就是这个判断真正落地的地方,从粗到细三种:

  1. 按入口分(最常见) :不同功能天生是不同架构------"快速问答"输入框调 chat,"数据分析助手"功能调 runAgent。用户点了哪个功能就决定了走哪条路,判断在你设计功能时就做完了,运行时都不用再判
  2. 按规则分:用关键词/长度等简单规则预判,比如含"对比""并且""顺便"等多任务信号就走 Agent。糙但零成本。
  3. 让一个模型先分类(进阶) :先用一次便宜调用问"这问题需要多步工具吗?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。如需转载请注明出处。 如果这篇文章帮到了你,欢迎在以下平台关注、交流:

相关推荐
接着奏乐接着舞。2 小时前
【2026年7月最新】69道RAG面试题
前端·人工智能·后端·aigc·embedding·rag
断眉的派大星2 小时前
YOLO实例分割详细解析
人工智能·yolo·计算机视觉
会周易的程序员2 小时前
使用 pybind11 封装 C++ 日志库 microLog 为 Python 模块
java·c++·python·物联网·架构·日志·aiot
为自己_带盐2 小时前
.NET 项目集成可观测性:从架构思考到落地实践
架构
DogDaoDao2 小时前
LLM:用一条命令统一所有大语言模型的 CLI 工具
人工智能·语言模型·自然语言处理·llm·github
ZRS的AI笔记2 小时前
AI Agent 工具链演进:从框架混战到协议标准化
人工智能
风止何安啊2 小时前
🚦 前端并发请求 “交通管制”:别让你的接口堵成早高峰
前端·javascript·面试
J船长2 小时前
扫盲烂笔头:A 记录、CNAME 的作用, Nginx的用途,为啥叫反向代理
前端
妙码生花2 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(二十六):icon 组件封装
前端·vue.js·typescript