前一阵子有位前端同事跑过来问我“想搞 Agent是不是得先去啃三个月深度学习再买几张显卡把模型训练出来”我说真不用。我自己的感觉是很多人一听“Agent开发”下意识就把它和模型训练画了等号结果还没开始就被吓退了。可实际做下来Agent开发更像我们熟悉的软件工程现成的大模型就像一台已经组装好的“发动机”你要做的是编写代码把它的能力接出来、约束住、编排好让它能完成具体任务。作为一个前端转过来的开发者我想把这套“入门认知”彻底掰开揉碎讲清楚。这篇文章适合所有前端背景、但对 Agent 有兴趣又有点懵的人。我会解释模型训练和 Agent 开发到底哪里不同拆解一个最小 Agent 的组成然后带你把一个“能调用工具”的简单 Agent 从头写出来最后分享一下我踩过的坑和现在常用的写法。看完你大概会明白Agent 开发的门槛远比你想象的低前端那些年积累的技能大部分都能平移过来。1. 先破除执念Agent开发到底在做什么1.1 模型训练和 Agent 开发一个是“造大脑”一个是“用大脑”我刚开始接触这个领域时也犯过糊涂总感觉“做 Agent”就等于“做 AI”。后来才发现这里混淆了两件完全不一样的事。模型训练是什么简单说就是你准备一大堆数据用代码去调整一个神经网络里的海量参数让模型具备某种能力。这需要批量喂数据、跑前向传播和反向传播、设计损失函数、观察训练 Loss 曲线后面还可能涉及分布式训练、混合精度、模型评估等等。整个过程的算力成本、数据成本和调试难度都很高通常是大模型团队或者说有充足资源的人在搞。Agent 开发呢它的核心是把已经训练好的模型当作一个“推理大脑”来调用。你不用关心模型的参数怎么来只需要通过接口把问题丢给它让它返回文本或者结构化的指令。Agent 的重点是围绕这个模型搭建出能完成任务的系统设计角色的系统提示词、定义它能调用的工具、管理多轮对话的上下文、处理各种边界情况和错误。这些本质上是工程问题不是科研问题。做类比的话模型训练像是在研发一台高性能发动机要考虑燃烧效率、缸体材料、散热设计Agent 开发更像是把这台发动机装进车里再设计油门、刹车、转向和车机屏幕。对于一个前端来说我们长期在做的事情其实和后者高度重合——把一个强大但“裸奔”的能力包装成可控、好用、有反馈的产品。基于这个认知你应该明白两件事第一做 Agent 不需要自己去训练模型所以“深度学习数学很难”“显卡很贵”这些顾虑基本可以放下第二Agent 开发里真正花时间的往往是提示词、工具设计、异常处理和流程编排这些恰恰是工程师日常的工作内容。提示在团队里如果有人说“我们要做个 Agent”最好先确认他到底是想“训练一个模型”还是想“用现成模型搭一个系统”。这两者的资源投入和技能栈完全是两个世界。1.2 前端技能在 Agent 开发里有多能打我做了多年前端一开始担心自己不懂算法、不懂数学做不好 Agent。实际做下来发现前端日常训练出来的几个核心能力在 Agent 开发里几乎全都能用上。第一是异步处理。前端每天都在和异步打交道请求接口、等待用户操作、处理并发更新。Agent 的一次任务往往不是“一问一答”而是反复地“问模型 → 模型要调工具 → 执行工具 → 把结果给模型 → 模型再组织答案”这个过程天然就是异步循环。你在前端里写惯了 Promise、async/await在这里写起 Agent 循环会非常顺手。第二是状态管理。前端管理 UI 状态时候选过 Redux、Context、Pinia本质上就是管理一份会变化的数据。Agent 里的“消息数组”其实就是类似的状态载体——每一次模型调用、每一个工具返回结果都被塞进同一个上下文数组里作为下一轮决策的依据。理解了状态流就理解了 Agent 的上下文管理。第三是组件化思维。前端讲究组件拆分、职责单一、复用抽象。Agent 开发里的“工具”也完全是一套组件化思路每个工具是一个独立函数有输入参数、有返回结构、有错误码注册给模型以后按需调用。你把工具想成一个个“函数组件”就很容易设计出清晰的工具集。第四是错误处理与联调经验。前端调后端接口时处理过超时、HTTP 状态码、重试、参数格式错误这套经验直接能迁移到大模型 API 上。模型接口一样会限流、会超时、会莫名返回乱结构你只需要把“接口联调”的耐心拿出来。第五是用户视角。前端是最懂用户的人。Agent 的提示词写得好不好很大程度取决于你能否想象用户会用什么方式提问、会在哪里产生歧义、会期望什么样的答案风格。前端对“体验”的敏感度到了提示词设计里就是很大的优势。所以别觉得自己是“非科班”。你缺的不是模型原理而是把“模型调用 工具执行 流程控制”融成一件事的系统思维。2. Agent的核心组成四个模块看懂一个最小Agent2.1 一个最小 Agent 由什么构成在我做过的模拟项目 X 里最小可运行的 Agent 其实只有四块模型调用层、系统提示词、工具注册层和 Agent 循环。模型调用层负责和某个大模型 API 通信。你给它一个消息数组和工具声明列表它返回模型的输出。这个接口通常是在远程完成的本地只需要一个 HTTP 请求或者一个 SDK 方法。消息数组里会包含系统消息、用户消息、助手消息、工具消息四种角色模型根据整个上下文继续生成内容。系统提示词用来设定 Agent 的身份和规则。你可以让它扮演客服、数据分析助手或者代码审阅者也可以规定输出格式、语气风格、禁止事项。这个提示词是 Agent 的“行为准则”前端可以把它理解成一份很严格的组件 props 契约。工具注册层就是把 Agent 能执行的能力列出来。模型本身没有能力去查数据库、调接口、读写文件所以我们通过“工具声明”告诉它如果你需要天气信息可以调用get_weather这个工具参数是什么返回什么。模型不会真的执行工具它只是决定“应该调哪个工具、传什么参数”真正执行动作的是你的代码。Agent 循环则是把上面所有模块串起来的核心机制。简单说就是把用户消息发给模型如果模型返回“要调用工具”就执行工具并把结果回填再继续把完整上下文发给模型如果模型不再调用工具而是给出最终回答循环就结束。这个“决策—执行—反馈”的循环就是 Agent 最基本的工作方式。理解这套结构以后你会发现Agent 本身并不神秘。它更像一个“会做决定”的中控系统真正动手干活的是你注册的那些工具。2.2 工具调用的完整时序拆解我用一个具体的例子拆解一下工具调用Tool Calling的完整流程。假设用户问“帮我查一下明天上海天气。”第一轮你的代码把系统提示词和用户这句话组成消息数组发给模型。模型看完以后如果识别出“查天气”属于工具职责它不会直接回答气温多少度而是返回一个结构化的“工具调用指令”例如“我需要调用 get_weather参数 city上海date2025-05-20”。第二轮你的代码检测到模型返回了工具调用指令于是去执行真实的天气查询。这个查询可能是请求某个天气服务也可能是读缓存数据库。拿到结果以后把结果包装成一条“工具消息”追加到原来的消息数组里。第三轮把包含工具结果的消息数组再次发给模型。模型看完实际的天气数据后就会组织最终回答“明天上海多云27 度东南风3级建议带伞。”然后正常返回文本不再包含工具调用指令循环结束。这里有个关键点每一次工具调用都是“模型决策 程序执行”的一次配合。模型负责从语义层面判断“该做什么”代码负责从物理世界拿数据、做操作。两者缺一不可。你在前端里处理过“审核—提交—回执”这类流程的话会觉得这个循环非常眼熟。注意模型返回“工具调用指令”和返回最终文本在响应结构里往往是通过不同的字段区分的。代码里要直接判断这些字段而不是去猜字符串。否则很容易把模型打算调用工具的意图当成正经回答闹出“好的我现在帮你查天气”这种话。3. 前端人实操搭建一个带工具的简单Agent3.1 环境准备真的不需要买显卡下面我带你从头写一个最小可用的 Agent。目标很简单它能听懂用户询问天气并调用一个本地写死的get_weather函数来返回结果。需要的环境就三样Node.js 18 以上、一个可调用工具能力的大模型 API Key、以及一个编辑器。全程不需要 GPU不需要训练脚本连 Python 环境都不用。我用 TypeScript 写因为前端熟悉如果你习惯 JavaScript删掉类型标注也一样能跑。工程结构我建议这样组织保持“工具”和“循环”分离agent-demo/ ├── src/ │ ├── tools/ │ │ ├── weather.ts │ │ └── index.ts │ ├── agent.ts │ ├── llm.ts │ └── index.ts ├── .env └── package.json.env里放模型 API 的地址、Key 和模型名。这样后续换模型服务商只需要改配置不会污染代码。3.2 实现一个“查天气工具”注册工具声明是整个 Agent 设计里最重要的一步。它决定了模型什么时候会调用工具、以及调用时参数规不规范。我看过很多新手把工具描述写得非常敷衍比如只写“查询天气”结果模型根本不知道该在什么时候触发调用。下面这个版本就具体得多// src/tools/weather.ts export const weatherTool { type: function, function: { name: get_weather, description: 查询指定城市、指定日期的天气情况。当用户询问实时或预报天气、温度、降雨概率、风力等与天气相关的问题时必须调用本工具。, parameters: { type: object, properties: { city: { type: string, description: 城市中文名例如上海、北京、广州。不要使用拼音。, }, date: { type: string, description: 日期格式为YYYY-MM-DD。如果用户说“明天”请根据当前日期换算后再传入。, }, }, required: [city, date], }, }, }; type WeatherArgs { city: string; date: string; }; export async function executeWeather(args: WeatherArgs) { // 实际项目中可以在这里请求真实天气服务 // 当前版本用固定数据模拟便于观察 Agent 循环 const result { city: args.city, date: args.date, condition: 晴, temperature_max: 27, temperature_min: 18, humidity: 46, wind: 东南风3级, }; return JSON.stringify(result); }这里为什么强调“日期要换算”因为模型默认不知道当天是几号如果用户说“明天”它可能直接填一个错误日期。你在参数描述里要求它换算它就知道要去上下文里找当前日期或者在系统提示词里明确给出“今天是某年某月某日”。工具返回格式我建议全部统一成 JSON 字符串。这样既保留结构化信息又不会让模型被额外格式干扰。返回的内容里不要写多余解释因为模型只需要看到精准的事实数据就能组织自己的语言。3.3 实现大模型调用封装大模型调用的封装我习惯直接用fetch写不依赖特定的 SDK。这样换任何一家服务商只需要改请求地址和请求体结构。对前端来说这种写法也最好理解。// src/llm.ts export type ChatMessage { role: system | user | assistant | tool; content?: string; tool_calls?: ToolCall[]; tool_call_id?: string; }; type ToolCall { id: string; function: { name: string; arguments: string; }; }; type LLMResponse { choices?: { message: { content?: string; tool_calls?: ToolCall[]; role: assistant; }; }[]; }; export async function callLLM( messages: ChatMessage[], tools: unknown[] ): PromiseLLMResponse { const endpoint process.env.LLM_API_ENDPOINT!; const apiKey process.env.LLM_API_KEY!; const modelName process.env.LLM_MODEL_NAME!; const resp await fetch(endpoint, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey}, }, body: JSON.stringify({ model: modelName, messages, tools, }), }); if (!resp.ok) { const text await resp.text(); throw new Error(模型接口返回异常: ${resp.status} ${text}); } return resp.json(); }这个封装的优点是透明你能看到请求里发了什么、响应里拿到了什么。等后面需要加日志、加超时、加重试直接在这个函数里改就行。我不建议一开始就接入复杂的框架先用最朴素的 fetch 把事情跑通你会对核心概念有更直接的体感。3.4 实现 Agent 主循环主循环是整个 Agent 的心脏。我把它定义成一个函数接收用户输入循环调用模型发现工具调用就执行直到模型给出最终文本。// src/agent.ts import { callLLM, type ChatMessage } from ./llm; import { weatherTool, executeWeather } from ./tools/weather; const SYSTEM_PROMPT 你是一个友好的私人助手。回答用户问题时如果需要实时天气数据请严格通过 get_weather 工具获取不要编造数据。; const toolsMap: Recordstring, (args: any) Promisestring { get_weather: async (args) executeWeather(args), }; export async function runAgent(userInput: string): Promisestring { const messages: ChatMessage[] [ { role: system, content: SYSTEM_PROMPT }, { role: user, content: userInput }, ]; // 防止模型陷入无限工具调用循环 const MAX_ROUNDS 8; for (let round 0; round MAX_ROUNDS; round) { // 第一步调用模型 const response await callLLM(messages, [weatherTool]); const assistantMessage response.choices?.[0]?.message; if (!assistantMessage) { throw new Error(模型返回中没有找到消息内容); } // 第二步把助手消息加入上下文这是保证多轮一致性的关键 messages.push(assistantMessage); // 第三步如果模型不再请求工具说明已经生成最终回答 const toolCalls assistantMessage.tool_calls ?? []; if (toolCalls.length 0) { return assistantMessage.content ?? ; } // 第四步循环执行每个工具调用 for (const call of toolCalls) { const toolFn toolsMap[call.function.name]; // 如果模型调用了一个未注册的工具也要给模型一个明确反馈 if (!toolFn) { messages.push({ role: tool, tool_call_id: call.id, content: JSON.stringify({ error: TOOL_NOT_FOUND, message: 没有找到名为${call.function.name}的工具, }), }); continue; } let content: string; try { const args JSON.parse(call.function.arguments); content await toolFn(args); } catch (error) { content JSON.stringify({ error: TOOL_EXECUTION_FAILED, message: error instanceof Error ? error.message : 工具执行失败, }); } // 第五步将工具的执行结果作为 tool 消息放入上下文 messages.push({ role: tool, tool_call_id: call.id, content, }); // 回到下一轮循环把包含工具结果的新上下文交给模型 } } throw new Error(Agent 超过了最大执行轮次 ${MAX_ROUNDS}未能产生最终结果); }这个循环里最容易被忽略的是messages.push(assistantMessage)。很多新手会把模型返回的内容单独处理却忘了把它放回上下文。结果下一轮模型就不知道“上一轮它自己决定要调什么工具”整个推理链就断了。前端的类比是你在组件更新后没有把新状态写回流界面当然会表现得很奇怪。MAX_ROUNDS上限也很重要。模型在复杂场景下可能反复调用同一个工具如果出错信息没被正确回填它可能会在原地打转。设一个上限至少不会让程序无限挂起。3.5 运行一次看看消息流长什么样为了让你直观感受循环我模拟一次“明天上海天气”的查询。真实运行时你能在请求日志里看到类似这样的消息流第一次调用模型时消息数组里只有系统提示词和用户消息。模型返回的 message 里不带content而是带一个tool_calls数组内容是nameget_weather, arguments{city:上海,date:2025-05-20}。随后代码执行本地工具返回一串 JSON{city:上海,date:2025-05-20,condition:晴,temperature_max:27,...}。这串 JSON 被作为一条role: tool的消息追加到上下文。然后再次调用模型。第二次调用时模型看到工具返回的实际数据就会生成最终文本比如“明天上海天气为晴最高27度最低18度东南风3级适合出门。”这次返回没有tool_calls循环结束你的 Agent 把这段文本展示给用户。整个过程的“中间产物”其实都是普通的数据结构没有黑魔法。你只需要把每轮消息完整保存好模型自然能接上上下文。这也是为什么我强烈建议在前几轮调试时把消息流打出来看看特别是第一次接触 Tool Calling 的人看完一次真实消息流比读十篇文档都有用。4. 前端入Agent最容易踩的5个坑4.1 上下文窗口和 Token 预算状态不是无限存的前端管理状态时经常假设内存足够大列表数据可以随便存组件状态可以在内存里长期保持。但大模型的输入有一个严格限制——上下文窗口一般只有几千到十几万 Token 不等。Token 可以粗略理解成模型处理文本的最小单元中文里一个汉字大约对应不到一个 Token一段对话很快就可能把窗口塞满。实际操作中如果一次性发给模型太多历史消息可能触发两种结果接口直接报错或者模型只读取了输入的一部分有些服务商会静默截断。无论哪种都不是我们想要的。你需要像控制浏览器内存一样控制上下文长度。我的习惯是每轮对话控制在一定的消息条数以内超出时把最早的消息丢弃或者先用模型对早前内容做一次摘要再把摘要作为新的上下文起点。前端经常做“列表分页”上下文管理其实也是分页思想——只保留最近一段用户真正关心的对话。4.2 工具返回值不规范模型开始“臆想”我见过很典型的一次问题工具函数返回了一个结构杂乱的对象里面既有业务数据又混入了执行日志和调试信息。模型拿到这段内容以后分不清哪些是真实数据、哪些是日志于是开始编造天气情况甚至反复去调用同一个工具试图找到更“干净”的答案。正确的做法是工具返回值永远保持简洁、结构化、稳定。成功时返回一个只包含关键字段的 JSON失败时返回一个明确的错误码和错误信息。例如查不到城市时可以返回{error:CITY_NOT_FOUND,message:无法识别该城市名称请让用户提供完整城市名}。模型看到这个错误码就会知道不能继续用老参数重试而应该向用户询问新的城市名。注意工具执行异常不要直接在函数里 throw 到外层让 Agent 崩溃。更合理的方式是把异常信息序列化后返回给模型让模型用自然语言向用户解释“查询失败”。毕竟用户面对的是一个对话界面而不是一个报错弹窗。4.3 把大模型 API 当成普通接口处理超时和重试前端联调时一个接口超过 5 秒就很容易焦虑甚至马上重试。但大模型 API 的生成时间是秒级起步复杂问题几十秒也很常见。如果你把超时时间设置在 8 秒以下会发现大部分请求都会“失败”然后你陷入频繁重试的恶性循环只会更快触发限流。我在模拟项目 X 里遇到的问题就是如此一个工具调用加上模型推理总耗时可能十几秒前端默认的fetch超时根本扛不住。后来的方案是显式设置请求超时为 60 秒并且对错误码进行分级处理如果是网络闪断可以退避重试如果是 4xx 参数错误就不要重试直接检查请求体如果是限流429 或类似状态码要退避更长时间再试比如第一次等 1 秒、第二次 2 秒、第三次 4 秒再叠加随机抖动避免多个请求同时打进来。前端的“重试直觉”在这里要升级核心是区分“应该重试的错误”和“重试也没用的错误”否则只是把自己的服务压垮。4.4 工具描述写得太“抽象”模型不知道该不该调工具的description决定模型的使用判断。我踩过最深的一个坑是把工具描述写成了“查询数据”结果模型在用户随意聊家常的时候也去调用这个工具返回了一堆无意义的默认数据。后来我把描述改成“当用户明确询问某个具体指标例如销售额、订单量、用户数时才调用此工具”误调用的情况立刻少了很多。参数描述同样要具体。比如日期参数你写“用户询问的日期”模型就可能填错格式你写“格式YYYY-MM-DD如果用户说今天或明天请基于系统当前日期计算后传入”模型就会认真处理。这些看起来像是“写给模型看的注释”实际上就是工具设计的灵魂。前端写组件时要写 TypeScript 类型、要写文档注释工具声明就是给模型看的“类型定义”。4.5 断点调试基本没用消息落盘才是王道前端的调试经验里console.log和debugger是神兵利器。但到了 Agent 开发里主体逻辑不在你的代码里而在模型内部。你在代码里打断点只能看到请求发出去了、响应回来了中间那几步“模型为什么这么决定”完全是个黑盒。如果模型输出不符合预期最难的往往不是改代码而是搞清楚它看到了什么、为什么这么判断。我现在的做法是给每次 Agent 运行分配一个traceId把每一轮完整的消息数组、模型返回原文、工具执行结果全部追加到 JSONL 日志文件里。这样排查问题的时候就变成“回放现场”而不是盲猜。日志格式大概是这样{traceId:abc123,round:1,type:request,messages:[...]} {traceId:abc123,round:1,type:response,message:{...}} {traceId:abc123,round:1,type:tool_execute,tool:get_weather,args:{...}} {traceId:abc123,round:1,type:tool_result,content:{...}}如果产品反馈“用户问某问题时结果不对”直接把 traceId 拿过来看一遍消息流是工具参数解析错了、返回值不干净、还是模型自己在胡扯一眼就能看出来。前端讲究可观测性Agent 开发更讲究可回放性。5. 再做几件事你的Agent会顺手很多5.1 用“工具能力清单”规划边界别让模型自由发挥Agent 能做什么完全取决于你注册了哪些工具。模型本身只是一个决策器并没有实际能力边界。所以你在设计阶段就应该像画功能清单一样明确地写下来它需要能查天气、读订单、发通知、还是操纵测试环境工具粒度是细一点还是粗一点我个人的经验是宁可工具多、每个工具职责单一也不要工具大而全。因为工具越单一模型就越容易判断该调哪个参数越精简模型传错的概率就越低。前端设计组件时讲究“高内聚低耦合”工具设计也是如此。给模型注册几个工具只是起步真正的工程难点在于设计一个合理的“能力边界”哪些行为允许 Agent 自己做哪些行为必须二次确认。比如一个能发邮件的 Agent如果用户的指令稍有歧义就直接发送等于埋了一个定时炸弹。这个控制在代码里实现而不是指望模型自己“懂事”。5.2 花点时间建立评估集模型改动得像前端回归测试前端项目有测试用例Agent 项目却经常是“拍脑袋式验收”人看一遍回答觉得差不多就上线。但模型输出是不确定的同一个问题今天回答得不错换个提示词、换个工具描述可能就崩了。没有一套稳定验收标准你根本无法判断改动是好是坏。我建议你给 Agent 准备一个“评估集”准备 20 到 50 条典型用户问题以及每条问题期望达到的标准比如“必须调用 get_weather 工具”“最终回答必须包含温度数据”“不能编造风力信息”。每次改完提示词或工具描述就把评估集跑一遍记录通过率、平均耗时、Token 消耗。这其实就是前端的回归测试思路只是断言从“界面元素存在”变成了“回答内容和行为符合预期”。这不是一个可选的加分项。Agent 是概率系统你只有靠评估集才能把“我觉得它能用”变成“它确实能稳定完成任务”。5.3 警惕把 Agent 当“加强版搜索框”的想法很多前端同行刚上手时会下意识把 Agent 定义成一个“更聪明的搜索框”用户提问它回答。这种理解会让你的 Agent 非常弱。如果只是搜索和问答你会发现自己只是在“套一个大模型壳子”既不稳定也没多大价值。Agent 真正值钱的地方是“执行闭环”它能在决策之后调用你写好的工具去完成一个真实动作比如生成一份报表、把记录写入数据库、一键配置一套测试环境。它不是一个回答问题的百科而是一个会动手的操作员。所以设计需求时你要多问一句“这个 Agent 最后要完成什么动作”如果答案是“不需要动作只回答”那就回归到普通问答系统不需要花大力气做 Agent。把这些思路理清楚以后你会发现 Agent 开发的大门其实很宽。前端那些年积累的异步思维、状态管理、组件化设计、联调经验全都能变成你的底气。别人还在纠结“模型会不会说得不对”的时候你已经能把模型装进一套完善的工程体系里让它稳定地为你打工。我个人实际做下来最大的体会是做 Agent 最难的从来不是“调用模型”而是“设计交互协议”。工具声明是协议提示词是协议消息数组也是协议。你要做的就是把用户需求翻译成模型和代码都能理解的标准化沟通方式。前端本来就是一门“设计人机交互”的手艺转到 Agent 开发只是把交互对象从浏览器里的用户换成了一个大模型。最后分享一个小技巧第一次做 Agent 时不要贪大可以找一个高频、受限、需要实时数据的场景练手比如“查天气”或“查待办事项”。把闭环跑通之后再往里面加工具、加评估、加日志。等这条跑道走熟了你会发现自己已经完全跨进了这个领域的大门。
阅读完成 · 觉得有帮助?