我最近把“前端能不能做 Agent”当成一个严肃问题想了很久,最后得出的结论很明确:能,而且前端之前积累的那套思维方式,恰恰是做 Agent 最缺的东西。很多后端和算法背景的人困在“模型能力”里,前端反而擅长把不稳定的系统接成一个可用的产品。这篇文章想聊的,就是我从“以为要学模型训练”到“原来是在写一种新的前端”的完整认知转变,也会给出一个可以直接动手的最小 Agent 实现,适合第一次接触 Agent 开发、但已经写过 React/Vue 的前端同学参考。
Agent 开发不是模型训练,这句话是我踩了很多坑之后才真正理解的。刚接触 Agent 时,我下意识觉得这是算法工程师的活,需要懂梯度、懂微调、懂 GPU 显存。实际做下来发现,大多数项目根本不需要碰训练,真正消耗精力的,是接口封装、状态管理、异常处理、上下文窗口控制,这些全是前端每天都在处理的问题。下面我会把它们一个个对应起来讲,尽量让有前端基础的人能少走弯路。
1. 认知的十字路口:Agent 开发到底在做什么
1.1 训练模型和开发 Agent 是两套完全不同的工作
先给“训练模型”画一个准确画像:训练是拿大量数据,通过反向传播不断调整模型内部参数,让模型从统计规律中学会某种能力。这个过程涉及数据清洗、算力调度、损失函数设计、评估集划分,结果是一个被你“调教”过的权重文件。它像研究材料科学,核心工作是改变材料本身的物理特性。
Agent 开发完全不同。Agent 不改变模型的任何参数,它只是在模型能力之上搭建一套运行逻辑:我给模型一段指令,它产出文本或调用工具,我把结果喂回去,再产出一轮新的内容,直到完成目标。你把模型换成任何一个支持对话接口的 LLM,Agent 的代码结构基本不用动。所以 Agent 开发更像开餐厅:厨师(模型)的厨艺是固定的,你要做的不是教厨师做新菜,而是设计菜单、安排传菜路线、处理客人退菜、控制每道菜的上菜顺序和时间。菜单是提示词,传菜路线是工具调用,退菜逻辑是异常处理,上菜节奏是编排策略。
这个类比解释了为什么前端能快速上手。一个 Agent 系统的核心复杂度,不在“模型有多聪明”,而在“模型、工具、记忆、用户目标之间怎么协作”。协作问题是工程问题,工程问题恰好是前端每天在解决的问题。你担心 DOM 更新顺序、担心接口并发、担心组件卸载后还有异步回调,这些对系统不稳定性的敏感,在 Agent 开发里就是最大的优势。
1.2 前端技能迁移过去并不是重新学,而是换个战场
把前端技能平移到 Agent 开发,可以从这几个维度看:
- 组件化思想对应工具拆分。前端把一个复杂的登录页拆成表单、校验、按钮、请求多个组件;Agent 也是把一个复杂任务拆成多个工具,比如查库存、算价格、生成订单。工具就是 Agent 的“函数组件”,输入输出协议就是 props。
- 异步编程对应 Agent 的等待模型调用。Agent 每一步都是在等网络返回,超时、重试、竞态处理,跟 axios 请求封装没有本质区别。
- 状态管理对应上下文管理。React 里你会思考哪些状态该放全局、哪些放局部;Agent 里你要思考哪些信息该留在上下文窗口里、哪些该压缩成摘要、哪些该丢到长期记忆里。
- 错误边界对应护栏设计。前端用 ErrorBoundary 兜住渲染错误;Agent 需要兜住模型输出格式错误、工具调用参数错误、循环不终止。
这不是硬凑类比,是同一个思维模式换了个壳。我在实际做第一个 Agent 时,发现自己无意识地在用“接口设计”的思路设计提示词:明确职责、控制输入、约束输出。后来才发现,这就是前端做组件设计时一直在做的事。
1.3 一个足够准确的心智模型:Agent 是一个带循环的状态机
如果非要用一个词概括 Agent 的核心结构,我会选“状态机”。前端写过复杂表单的人都知道,状态机能让混乱的交互流变得可预测。Agent 也一样:它有一个状态(当前的消息历史、已完成的目标、剩余步骤),根据当前状态决定下一步动作(调用模型、执行工具、结束任务),再根据结果更新状态。
这个状态机不是数学概念,它就是你代码里的一个 while 循环。模型每次只决定“下一步该做什么”,工具执行结果改变环境,循环再次进入模型。所以 Agent 开发从头到尾都是在写这个循环:什么时候该结束、什么时候必须调用工具、模型输出不符合预期时怎么回退。理解了这一点,再看后面所有框架和工具,本质都是在帮你更好地管理这个循环。
2. 一个 Agent 的四块积木,用前端概念就能吃透
2.1 LLM 调用:不过是一次普通的异步请求
很多前端把 LLM 调用想得太神秘,其实它就是一个标准的 HTTP POST 请求。你往接口发一个消息数组,它返回一个回复,完事。和请求普通后端接口唯一的区别是,接口会读你传进去的整个消息历史来决定怎么回答。
一次最简单的调用长这样:
const response = await fetch('https://api.example-llm.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.LLM_API_KEY}` }, body: JSON.stringify({ model: 'base-chat', messages: [ { role: 'system', content: '你是一个帮助用户安排日程的助手。' }, { role: 'user', content: '明天下午三点安排一个产品评审会' } ] }) }); const data = await response.json(); console.log(data.choices[0].message.content);做过 api 封装的同学看到这个结构应该很亲切。messages 数组就是 request body,choices[0].message 就是 response.data。区别只在:请求是无状态的,你必须把历史消息都带上,模型才能记得你们之前聊了什么。这很像你把整个表单的所有字段一次性提交给后端,而不是只提交最后一个输入框。
所以第一步就是把“调用 LLM”封装成一个你熟悉的 apiClient。超时、重试、错误码映射、日志记录,全都可以复用之前调后端接口的经验。你唯一要适应的,是响应不一定是合法 JSON,需要做校验和兜底。
2.2 工具调用:JSON Schema 版的接口联调
Agent 和普通聊天机器人最大的区别,就是它能在对话过程中调用工具。怎么实现的?不是模型偷偷长了手,而是你把工具的描述和参数格式发给模型,让模型输出一个“我想调用这个函数”的结构化结果。
这一块对前端最友好,因为它本质上是在做接口联调。你定义一个工具的时候,就是在写一份轻量级的接口协议:
[ { "type": "function", "function": { "name": "get_weather", "description": "查询一个城市当前的天气情况", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,比如杭州" } }, "required": ["city"] } } } ]把这份工具描述放进请求里,模型在需要时会返回一个 tool_calls 字段:
{ "choices": [ { "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"杭州\"}" } } ] } } ] }你的代码要做的事情很明确:看到 tool_calls 就解析,根据函数名找到本地实现,执行,然后把执行结果作为一个新的消息塞回对话里。这就是前端的接口调用封装:收到响应,校验参数,调用本地函数,回传结果。唯一要注意的是 arguments 是字符串,需要 JSON.parse,而且模型偶尔会输出不合法 JSON,这就要加 try/catch。
2.3 记忆与上下文管理:状态管理的老朋友
前端开发里,你绝对不会把整个应用的所有状态都放在一个永不清理的内存对象里,该持久化的持久化、该缓存失效的失效、该清理的清理。Agent 的记忆管理也是一回事,只是换了个术语。
上下文窗口就是 Agent 的“工作内存”,所有要发给模型的信息都必须放在里面。窗口有限,塞太多内容模型会遗忘前面的指令,响应质量也会下降。控制上下文,本质就是给工作内存做 GC。常用的手段有三类:
- 滑动窗口:只保留最近 N 轮对话,更早的直接丢弃。适合客服、闲聊这类短期场景。
- 摘要压缩:历史太长时,让模型先把旧对话压缩成一段摘要,再和最近对话一起放回上下文。类似前端把旧的日志做成统计报表,而不是保留原始流水。
- 外部记忆:把用户资料、业务数据放到向量库或普通数据库里,需要时检索出来再拼进上下文。这就更像后端缓存 + 数据库的组合,只是检索时用了向量相似度而不是 SQL。
我在项目里经常把这三层叠加用:短期对话走滑动窗口,中期信息走摘要,长期用户偏好走向量库。最开始我也觉得记忆很难,后来发现它就是“缓存策略 + 持久化方案”的 AI 版,思路可以完全照搬。
2.4 编排与循环:Agent 的大脑是代码,不是模型
很多人以为 Agent 的“智能”全来自模型,但真正的决策流程,其实是你写的编排代码。模型只负责一个动作:给定当前状态,预测下一步。如果模型说“我要调用工具”,你的代码就调;如果模型说“我已经完成了”,你的代码就停。这个编排循环通常长这样:
初始化 messages 和任务目标 循环: 把 messages 发给模型 如果模型要求调用工具: 解析工具名和参数 执行对应函数 把结果追加到 messages 继续下一轮循环 否则: 把模型回复返回给用户 结束循环这就是 ReAct 模式的简化版,也是当前大多数 Agent 框架的底层逻辑。你完全可以用一个 while 循环写出来,而不用引入任何框架。
前端思维在这里又一次派上用场:这个循环很像一个事件驱动的 reducer。工具调用结果就是 dispatch 的 action,messages 就是全局 state,每次循环都是根据 action 计算新 state。你写 React 时已经非常习惯这套模式了,只是以前 action 是用户点击,现在 action 变成 tool_calls。
3. 实操:一个最小 Agent 的完整实现过程
3.1 先跑通一个最朴素的循环
在写任何复杂功能之前,我强烈建议先跑通一个最小闭环:模型能收到消息、能返回回复、代码能把它呈现在控制台。这个闭环能验证 API key 配置、网络连通性、依赖版本,避免后面把问题混在一起排查。
我习惯用 TypeScript 写,但不依赖任何框架,只用一个 fetch。下面是一个极简骨架:
type Message = { role: 'system' | 'user' | 'assistant' | 'tool'; content: string | null; tool_calls?: ToolCall[]; name?: string; }; type ToolCall = { id: string; type: 'function'; function: { name: string; arguments: string; }; }; type Tool = { name: string; description: string; parameters: object; execute: (args: any) => Promise<string>; }; async function callLLM(messages: Message[], tools: Tool[]) { const response = await fetch('https://api.example-llm.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.LLM_API_KEY}` }, body: JSON.stringify({ model: 'base-chat', messages, tools: tools.map(({ name, description, parameters }) => ({ type: 'function', function: { name, description, parameters } })) }) }); const data = await response.json(); return data.choices[0].message; } async function agentRun(input: string, tools: Tool[], maxTurns = 5) { const messages: Message[] = [ { role: 'system', content: '你是运行在终端里的一个小助手,请尽量简洁地回答。' }, { role: 'user', content: input } ]; for (let turn = 0; turn < maxTurns; turn++) { const message = await callLLM(messages, tools); messages.push(message); // 如果没有工具调用,说明模型已经给出了最终答案 if (!message.tool_calls || message.tool_calls.length === 0) { return message.content; } // 逐个执行工具调用 for (const call of message.tool_calls) { const tool = tools.find((t) => t.name === call.function.name); if (!tool) { messages.push({ role: 'tool', tool_call_id: call.id, name: call.function.name, content: `未找到工具 ${call.function.name}` }); continue; } try { const args = JSON.parse(call.function.arguments); const result = await tool.execute(args); messages.push({ role: 'tool', tool_call_id: call.id, name: call.function.name, content: result }); } catch (e) { messages.push({ role: 'tool', tool_call_id: call.id, name: call.function.name, content: `工具执行失败: ${(e as Error).message}` }); } } } return '已达到最大轮次,任务未结束。'; }这个骨架看起来不起眼,但它已经是一个完整的 Agent:能对话、能调用工具、能处理工具报错、能防止无限循环。后面所有功能都是在它的基础上加东西。
3.2 给 Agent 接入一个真实工具
骨架有了,我们接一个最简单的工具进去。前端同学最熟悉的就是天气查询,这里我用一个模拟实现演示,真实场景里把它换成天气服务 API 即可。
const weatherTool: Tool = { name: 'get_weather', description: '查询指定城市当前天气。当用户问天气时使用。', parameters: { type: 'object', properties: { city: { type: 'string', description: '城市名称,例如 杭州' } }, required: ['city'] }, async execute({ city }) { // 这里换成真实的天气接口调用 const weatherMap: Record<string, string> = { 杭州: '阴,23 度,空气质量良', 上海: '小雨,25 度,空气质量优', 北京: '晴,26 度,空气质量轻度污染' }; return weatherMap[city] ?? `${city}:暂无数据`; } }; async function main() { const reply = await agentRun('杭州现在天气怎么样?', [weatherTool]); console.log(reply); } main();整个流程会是这样:模型判断用户想查杭州天气,输出一个 get_weather 的工具调用,参数是 city=杭州;你的 execute 函数从某个记录里拿到天气信息,把它作为 tool 消息返回给模型;模型看到天气信息后,组织成自然语言回复:“杭州现在阴,23 度,空气质量良。”
这个流程的关键认知是:模型本身不知道杭州天气,它靠外部工具拿到了数据。这就是工具调用的意义,也是 Agent 比普通对话模型“有用”的地方。前端在这个环节要注意:工具返回给模型的文本质量直接影响最终回答。尽量让工具返回结构清晰的数据,比如 JSON 字符串,而不是一段已经写好的口语化句子,给模型留出组织语言的空间。
3.3 加上流式输出、超时和错误处理,才算能上生产
我一开始做 Agent 时,用的都是非流式的接口,等全部生成完才一次返回。在终端里看着没问题,放到网页聊天界面里就出问题了:用户发出消息,可能要等好几秒才看到第一个字,体验非常差。后来才把普通请求改成流式请求。
流式请求的思路和普通请求不太一样:响应不是一次性返回完整的 JSON,而是一块一块推过来,像前端通过 WebSocket 接收消息一样,你需要一边接收一边渲染。
async function streamChat(messages: Message[], onDelta: (text: string) => void) { const response = await fetch('https://api.example-llm.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.LLM_API_KEY}` }, body: JSON.stringify({ model: 'base-chat', messages, stream: true }) }); const reader = response.body?.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (reader) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data: ')) continue; const data = trimmed.slice(6); if (data === '[DONE]') return; try { const parsed = JSON.parse(data); const delta = parsed.choices?.[0]?.delta?.content; if (delta) onDelta(delta); } catch { // 忽略不完整的 JSON 片段 } } } }这段代码需要配合 AbortController 做超时中断,用户取消对话时也需要它:
const controller = new AbortController(); // 10 秒无响应则中断 const timer = setTimeout(() => controller.abort(), 10000); fetch(url, { method: 'POST', headers, body, signal: controller.signal }).catch((error) => { if (error.name === 'AbortError') { console.log('请求超时,已中断'); } else { console.error('请求失败', error); } }).finally(() => clearTimeout(timer));生产环境里,除了流式,还有一个必须想清楚的错误处理策略:网络错误要重试,模型返回格式错误要兜底,工具执行失败要明确告诉模型。不要把这些错误直接抛给用户,让模型把错误“消化”成一句更自然的解释,比如“天气服务暂时不可用,请稍后再试”,体验会好很多。
4. 调试 Agent 的实战心得:把大模型当不稳定接口对待
4.1 从“代码崩溃”到“输出不对”,调试思维的转变
前端调试的核心场景是报错和崩溃:TypeError、404、白屏。Agent 调试的核心场景是输出不对:模型绕圈不结束、工具参数传错、回答太啰嗦、记不住前面说过的话。这些错误不会让程序崩溃,但会让产品不可用。
所以第一个要建立的观念是:把模型当成一个会给你输出“合法但不符合预期”内容的不稳定接口。你不能像信任本地函数一样信任模型的输出。所有从模型返回的结构化数据,都要做解析、校验、兜底。
我自己调试时最常用的手段,是把每次请求的完整消息历史打印出来,包括每一轮的用户输入、模型回复、工具调用、工具返回值。这个东西我会单独存成日志,格式和 Chrome DevTools 里的 Network 面板类似。问题出现时,先从日志定位是哪一轮开始偏离的。是模型没有正确识别用户意图?还是工具返回了脏数据?还是上下文太长把之前的指令冲掉了?每一个都有不同的解法。
4.2 高频问题排查速查表
下面是我在多个项目里整理出来的高频问题对照表,每一行都是真实踩过的坑:
| 问题现象 | 前端类比 | 推荐排查方法 |
|---|---|---|
| Agent 循环调用工具不结束 | 死循环或逻辑没有出口 | 设置最大轮次;检查工具返回值是否让模型误以为任务未完成;打印每轮 tool_calls 观察循环路径 |
| 上下文越塞越多,响应变慢且质量下降 | 内存泄漏 | 增加滑动窗口;对旧对话做摘要;检查是否不小心把长文档重复塞入系统提示 |
| 模型没有调用预期工具 | 参数传递错误 | 检查工具描述是否足够明确;确认模型版本是否支持工具调用;把工具名写得更直白,避免“getInfo”这种模糊命名 |
| 工具参数解析失败 | JSON.parse 抛错 | 在工具函数里 try/catch;用 JSON Schema 做二次校验;把模型返回的 arguments 先 console.log 出来检查 |
| 回答重复绕圈 | 逻辑分支缺失 | 在系统提示里加上“如果你已经给出答案,就不要再调用工具”;检查工具返回值是否给了太多选项 |
| 流式输出在界面卡顿 | 渲染性能问题 | 不要每收到一个 delta 就 setState 一次,用节流或批量合并;确认是否重复解析 Stream 数据 |
| 模型被用户带偏,输出不安全内容 | XSS/注入防护 | 在系统提示里写死约束范围;在展示层对模型输出做过滤,不要直接 innerHTML |
这张表不用背,当你把 Agent 当普通业务系统调,问题自然归到“输入、处理、输出”三个环节,排查思路和你调接口联调一模一样。
4.3 我的“护栏”设计清单
最后一个相对成熟的心得:生产级 Agent 不能只有一条“让模型自由发挥”的通道。我会在代码层面加几层护栏,类似前端给用户输入做白名单校验:
- 轮次上限。任何循环必须有硬性终点,防止模型钻牛角尖。
- 工具白名单。模型只能调用你注册过的工具,未注册的调用名直接报错返回给模型,而不是尝试执行。
- 输出格式校验。凡是模型要返回结构化内容的地方,都加 JSON Schema 校验,不合格就重试一次或转成自然语言提示用户。
- 敏感操作二次确认。涉及删除、支付、发送消息这类操作,先让模型产出“待确认状态”,再向用户确认,不能一步执行到底。
- 日志全量记录。每次调用都记录 messages、tool_calls、耗时、token 消耗。前端可能觉得这很笨重,但没有日志,Agent 出了问题根本没法复盘。
这几层护栏让系统变稳。它们不依赖更强的新模型,只需要你在开发时多写几层 if。前端对“边界情况”的天然敏感,在这个阶段就是别人复制不走的核心竞争力。
最后说点真实的开发体会
练手时,我的建议是把目标定得小一点:做一个能查天气、能记账、能帮你查节假日的小助手,都行。千万不要一开始就上多工具、多记忆、多层编排的宏大架构。Agent 这个领域最大的特点是失败隐蔽,一个问题可能要等系统跑好几轮才暴露,所以每次只加一个变量,验证稳定了再加下一个,比任何优雅设计都管用。
我个人实际做项目时,最常用的一句话是“限制条件永远写在最前面”。系统提示里一开始就说清“你只有这些工具,不能做列表之外的事”,后面出问题的概率会显著下降。前端开发里这叫接口约束,Agent 开发里这叫预设边界。说到底都是靠工程手段兜住系统的复杂度,模型只是这台机器里的一个部件而已。