
Agent 不是新东西:React 早就教过你这 8 件事
Agent 难,从来不是因为它新,而是因为没人拿你已经会的东西讲过它。
咱们前端这两年是真被折腾得够呛。
我去年也跟着凑热闹,找了本 LangChain 的文档从头啃。Chain、Memory、Retriever、Executor、Runnable,一个名词套一个名词,每个都看得懂,合起来完全不知道在干嘛。看完一遍,我唯一学会的技能是复制官方 example 然后改改 API key。
真正的转折不是我看懂了哪篇文档。是有天半夜我在调一个跑飞了的脚本,盯着终端里那串来回打转的日志,脑子里冒出来一句话——
这玩意儿怎么跟 useEffect 里忘了写依赖数组一模一样。
那一瞬间整个东西塌下来了。不是「我学会了 Agent」,是「我发现我早就会了,只是没人这么跟我说」。
后来我把这个感觉往下捋,捋出来八组对应关系。先说清楚:它们不是严格等价,底层机制并不相同,我借的是工程直觉。 但这八组直觉,能让你少走大半年弯路。
八组里有七组是「你以为要重学,其实换个名字而已」,还有一组是反着来的——也正是那一组,坑了我最久。
下面尽量讲得让刚入行的同学也能看懂。看看你哪几组是「早就会了却不知道自己会」的。
先立一个比方,后面八节我都会用它:
把大模型想象成一个刚入职的实习生。能力很强,脑子很快,但有个毛病——每天早上一睁眼,前一天的事忘得干干净净。
你要做的所有事,本质上都是在给这么一个人安排工作。
一、useState → Memory:它真的不记得你说过什么
📌 先看个场景。
电商客服 Agent。用户第一句「我上周买的耳机还没到」,第二句「订单号是 A10086」,第三句「那来得及吗」。
小白通常这么写:调一次接口给一句,每次新开一个请求。
结果第三句一出来,模型完全不知道「那」指的是什么。
刚上手的同学第一反应通常是:不对啊,我在网页版聊十几轮它都记得,怎么到接口就不行了?
但问题来了。
咱们这篇用的是 Chat Completions 这种无状态接口——模型这一侧,永远只看得见你这一次发过去的那个 messages 数组。数组里没有的,对它来说就不存在。
(现在也有带会话 ID、previous_response_id 的有状态接口,那是平台帮你存了。但存的还是同一份东西,只是换个人保管。)
这句话你换个说法就熟了:
codeReact:界面上有什么,由 state 决定 UI = f(state)
Agent:模型答什么,由 messages 决定 answer = f(messages)messages 就是你替模型维护的那份 state。
React 里有个原则叫单一数据源——一个东西的真相只存在一个地方,界面只是它的投影。你要是把某个值存在组件外面的变量里改来改去,界面不跟着变,你不会怪 React,你知道是自己没走 state。
咱们在 Agent 这边犯的是同一个错。你没塞进 messages 的信息,模型就是不知道,这不是模型笨。
高级工程师怎么做?把记忆拆成三层,各归各位:
jsconst messages = [
// 工作记忆:这次任务的规则,每次都带上
{ role: "system", content: "你是电商客服,回答控制在 30 字以内。" },
// 短期记忆:最近几轮对话,会滚动淘汰
{ role: "user", content: "我上周买的耳机还没到" },
{ role: "assistant", content: "方便提供一下订单号吗?" },
{ role: "user", content: "订单号是 A10086" },
];
// 长期记忆:不进 messages,需要的时候查出来再拼进去
const profile = await db.getUserProfile(userId);打个比方。
那个实习生每天早上失忆,你怎么让他干活?
你会给他一本工作笔记。第一页写「你的岗位职责」(system),后面几页记「今天上午客户说了什么」(短期记忆)。至于公司三年来所有客户资料,你不会抄进笔记本,你会告诉他「柜子在那儿,要用自己去查」(长期记忆)。
他不是记性差,他是每天从笔记本开始活。你写多少,他就知道多少。
💡 别走极端:三五轮的简单对话直接全塞 messages 就行。一上来就搭向量库、上 Redis 存会话,那叫过度设计。
二、useEffect → Tool Calling:决定和执行,是两个人的活
📌 先看个场景。
你想让客服 Agent 自己去查订单,于是定义了一个 get_order 工具交给模型。
小白通常这么写:把 tools 传过去,然后读 message.content,等着订单信息出现在里面。
写完看着挺合理,对吧?工具给了,描述也写了,模型该自己去查了。
但问题来了。
我在函数里埋了个计数器,跑完看它被调用了几次:
codefinish_reason : tool_calls
content : ""
tool_calls : [{"function":{"name":"get_order",
"arguments":"{\"orderId\": \"A10086\"}"}}]
>>> 此刻 getOrder 被执行了几次? -> 0零次。
content 是空的,模型回来的只是一段 JSON,意思是「我想调这个函数,参数是这个」。函数体一行都没跑。你得自己跑完、把结果塞回 messages,再问它第二遍,它才开口回答。
code你以为的 实际发生的
────────────── ──────────────
模型 → 调 getOrder() 模型 → 吐一段 JSON
→ 拿到结果 「我想调 get_order」
→ 回答你 你 → 自己执行 getOrder()
你 → 把结果塞回 messages
模型 → 这才开始回答心里把这条刻死:在你自己定义的 Function Calling 里,模型只负责生成一个调用请求。参数校验、权限判断、真正的执行,全在你的代码里。
这不就是那条老规矩吗——render 保持纯,副作用交出去。
(顺便把两句常听的话说准一点:不是「所有副作用都往 useEffect 里塞」。用户点一下就该发生的事,放事件处理函数;Effect 是用来让组件和外部系统保持同步的。另外服务端组件可以在渲染期直接读数据库,「render 不能发请求」说的是客户端。)
打个比方。
那个实习生被关在会议室里,门锁着,手机没收。他能干的唯一一件事,是写纸条从门缝底下塞出来:「帮我查一下 A10086 这个订单」。
跑出去查的是你。查完再从门缝塞一张纸进去告诉他结果,他才能接着往下说。
💡 Function Calling 这名字起坏了,它压根不 call。真要起名,该叫 Function Requesting。
三、依赖数组 → 上下文预算:给多了烧钱,给少了拿旧值
📌 先看个场景。
客服会话开到第二十轮,你发现两件事:账单在涨,回复在变慢。
前端这边有个几乎人人踩过的坑,长得特别像。我跑了一遍:
jsuseEffect(() => {
// 注册一个订阅,推送来了就读一下当前的 count
return subscribe(() => console.log(count));
}, []); // ← 依赖数组是空的code依赖 = [] | 界面上显示 3 | 回调里依次读到 [0, 0, 0]
依赖 = [count] | 界面上显示 3 | 回调里依次读到 [1, 2, 3]界面上明明是 3,回调里读到的还是 0。没写进依赖,闭包锁住的就是旧值。
上下文这边的毛病长得很像——你决定往里放什么,就决定了它能看见什么:
- 放少了 → 关键信息缺席,用户改过的地址它没看见,于是开始编
- 放旧了 → 过期信息还躺在上下文里,它照着旧的答
- 放多了 → 每轮把全部历史重发一遍,token 一路往上爬
(机制上这俩不是一回事:依赖数组是 React 运行时用 Object.is 比,写全没写全靠 ESLint 静态检查;上下文裁剪是你自己在运行时做的取舍。我借的是那个取舍的直觉——这一轮到底需要哪些。)
一段八轮对话,同一批消息跑两遍:
code❌ 全量累积 ✅ 只留最近 3 轮
────────────── ──────────────
第 1 轮 33 tokens 第 1 轮 33 tokens
第 4 轮 148 tokens 第 4 轮 117 tokens
第 8 轮 260 tokens 第 8 轮 105 tokens
────────────── ──────────────
累计 1219 tokens 累计 732 tokens
省下 40.0%左边一路往上爬,右边爬到第四轮就压平了。八轮就差 40%,真实客服会话动辄几十轮,这是复利。
但问题来了。
裁剪这事儿看着简单,我第一版就翻车了。当时写的是「保留最近 N 条」,跑边界的时候发现:历史条数一变奇数,切完 system 后面直接跟了个 assistant——一句用户的话都没有,模型上来先看见自己在说话。
我本来要在这儿写「这种形状接口会直接报错」,写之前顺手验了一下,又打脸了。孤儿 tool 消息、system 后直跟 assistant,两种畸形结构发过去全是 HTTP 200。而其中一次的返回是这样的:
已为您查询到订单 A10086……物流单号:SF1234567890,承运方:顺丰速运,预计明天送达……
单号是编的,承运方是编的,日期也是编的——这次对话里一个工具都没调过。
它不报错,它安静地编。 报错你当场就知道,编造你可能上线三个月才被客户投诉出来。
而且「按条切、看奇偶」这个思路本身就不够。真实对话里一个 assistant 可能同时发起好几个并行的 tool_calls,后面跟着好几条结果。你要保的不是奇偶,是一条不变量:
assistant 的
tool_calls和它对应的全部 tool 结果,不能被切开;切口只能落在一轮的开头。
所以得按「轮」分组再切:
ts/** 一条 user 开一组,后面跟着的 assistant / tool 都归这一组 */
function groupTurns(messages: ChatMessage[]): ChatMessage[][] {
const groups: ChatMessage[][] = [];
for (const m of messages) {
if (m.role === "user" || groups.length === 0) groups.push([m]);
else groups[groups.length - 1]!.push(m);
}
return groups;
}
function trimContext(messages: ChatMessage[], keepTurns = 3): ChatMessage[] {
let head = 0; // 开头连续的 system 永远留着
while (head < messages.length && messages[head]!.role === "system") head++;
const groups = groupTurns(messages.slice(head));
let kept = groups.slice(-keepTurns);
// 切口必须落在一轮开头:第一组不是 user 起头就整组丢掉
while (kept.length > 0 && kept[0]![0]!.role !== "user") kept = kept.slice(1);
return [...messages.slice(0, head), ...kept.flat()];
}说清楚它保证了什么:对本来就结构合法的历史,按轮切能保证切口不落在工具链中间。 它不校验 tool_call_id 对不对得上,也不管你传进来的历史本身是不是已经烂了——真上生产还得按 ID 做一层结构校验。
打个比方。
你要跟实习生交代一件事,是把过去三个月的聊天记录从头念给他听,还是说一句「客户姓王,要退款,订单在这」?
念全部记录,他确实什么都听到了,但他也听了两小时,还未必抓得住重点。
💡 别走极端:裁到只剩一轮,模型会失忆到连用户上一句问了啥都不知道。先从「最近 3-5 轮 + 一段滚动摘要」起步,再按账单调。
四、受控输入 → 结构化输出:说好的 JSON,形状是另一回事
📌 先看个场景。
把用户反馈自动抽成工单:情绪、涉及产品、紧急程度,抽完直接进数据库。
小白通常这么写——在 prompt 里说一句「抽取这三个字段,返回 JSON」。跑一下确实是 JSON,收工。看着没问题,对吧?
但问题来了。
我跑了 12 次,两级判定:能不能 JSON.parse,字段类型对不对。
12 次全能 parse,12 次形状全错。 我要的 urgency 是 1-5 的整数,模型给的是字符串 "high"。
加上 response_format: { type: "json_object" } 再跑 12 次——一点没变。
这里藏着一个大部分教程没讲清楚的区别:json_object 保证的是「输出是一段合法 JSON」,它不保证里面的字段符合你的业务 schema。语法和形状,是两回事。
而这两件事,接口其实是分开提供的:
codeA 档 json_object,prompt 不写类型 0/12 通过 schema 校验
B 档 json_object + prompt 里把类型写死 12/12
C 档 B 档 + 代码校验,不合法就回灌重试 12/12(这次一条都没用上重试)
D 档 json_schema strict,prompt 不写类型 12/12 ← 接口层直接管住了D 档是把 schema 交给接口,而不是用大白话求模型:
jsresponse_format: {
type: "json_schema",
json_schema: {
name: "feedback",
strict: true,
schema: {
type: "object",
properties: {
sentiment: { type: "string", enum: ["positive", "neutral", "negative"] },
product: { type: "string" },
urgency: { type: "integer", minimum: 1, maximum: 5 },
},
required: ["sentiment", "product", "urgency"],
additionalProperties: false,
},
},
}prompt 一个字没改,12 次全过。
我怕是巧合,又拿一个模型绝不可能自己猜出来的 schema 试了试——字段叫 zeta_code,枚举是 ALPHA / BETA / GAMMA,提示词里一个字不提。只开 json_object 时它自己发明了一堆中文键名(情绪、核心问题、建议行动),开了 json_schema 之后,10 次全部命中 {"q_index":1,"zeta_code":"BETA"} 这个形状。顺手再传个非法 schema,接口直接 HTTP 400 顶回来——它是真在读。
翻译成前端的话,这三档就是:
codejson_object <input>
只保证是个字符串,输入「很急」它也收
prompt 里写死类型 <input> + placeholder="请填 1-5"
靠说服,多数时候有效
json_schema strict <input type="number" min="1" max="5">
接口层直接把不合法的挡在门外React 里的非受控组件,你只能在提交那一刻去 ref.current.value 摸一把,摸到什么算什么。受控是 value 加 onChange——你规定它长什么样,每次变化都过你一遍手。
而大多数教程演示的「结构化输出」,其实只是 json_object 那一档,也就是个裸 <input>。
打个比方。
你让实习生交一份客户情况说明。你说「写成表格给我」,他给你的是表格——但「紧急程度」那栏他填了「挺急的」。
你得把表格提前印好,格子直接印成方框,一个格子一个数字,让他连「很急」这两个字都填不进去。
💡 别走极端,两件事:
一是schema 管形状,管不了判断。同一段反馈,两次跑出来的 urgency 一个是 1 一个是 5,两个都「合法」——它俩只是不一致,没有标准答案的话,谁对谁错我判不了。所以代码侧那层校验(Zod / Ajv / Pydantic)该留还得留,它兜的是业务规则。
二是这是我在 2026 年 8 月用 qwen-plus 测的结果。json_schema 各家支持程度不一样,同一家不同型号也不一样,上线前自己先探一次。
五、重渲染循环 → Agent Loop:得有出口
📌 先看个场景。
「查一下订单 A10086 到哪了,如果已经发货了,帮我算算还要几天到。」
这句话里藏着两次工具调用,第二次还依赖第一次的结果。模型没法一步做完。
React 里你太熟这个模式了:state 变了 → 重新 render → 如果 render 里又改了 state → 再 render → 直到不再变化。而你要是在 effect 里无条件 setState,React 直接甩你一句 Maximum update depth exceeded。
codeReact 的循环 Agent 的循环
───────────── ─────────────
state 变了 模型说要调工具
↓ ↓
重新 render 你执行工具
↓ ↓
产生新 state? 结果塞回 messages
↓ ↓
不变了 → 停 模型说不用调了 → 停
───────────── ─────────────
出口:state 收敛 出口:模型收口 / maxSteps
兜底:Maximum update depth 兜底:你自己写的 maxSteps循环的骨架就这么点东西——注意 specs 那个参数,第一版我把它漏了:
tsexport async function runAgent(input, tools, callModel, options = {}) {
const { maxSteps = 8, keepTurns = 6 } = options;
const specs = Object.values(tools).map((t) => t.spec); // 工具声明,交给模型
const messages = [{ role: "user", content: input }];
for (let step = 1; step <= maxSteps; step++) {
// 声明必须每一圈都随请求发出去
const { finishReason, message } = await callModel(trimContext(messages, keepTurns), specs);
messages.push(message);
// 模型不再要工具了,说明它收口了
if (finishReason !== "tool_calls" || !message.tool_calls) {
return { answer: message.content ?? "", steps: step, stoppedBy: "model" };
}
for (const call of message.tool_calls) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(await runTool(tools, call)),
});
}
}
return { answer: "这个任务我没能在限定步数内完成。", steps: maxSteps, stoppedBy: "maxSteps" };
}specs 那一行是我真踩过的坑。 第一版写成 callModel(messages)——类型检查照样过,跑起来也不报错,模型就是一次工具都不调,从头到尾在那儿说「抱歉我无法查询订单」。你定义了工具不等于模型知道有工具。
修好之后端到端跑一遍,我在 callModel 里加了个计数器盯着这件事:
code=== A:多步任务,工具正常 ===
发给模型的工具声明数量 : 3
工具调用轨迹 : get_order -> estimate_delivery
最终回答 : 订单 A10086 已发货,从上海发出,预计2天后送达。但问题来了。
把工具全换成一直返回「服务暂时不可用」,再加一句「查不到就一直重试」,看它转到什么时候。
说句实话,结果比我预期的懂事——它自己停下来了,敲了两三次门就收口,回一句「订单服务暂时不可用,请稍后再试」。
但你不能拿这个当设计依据。我前后跑了两次,一次退了三步,一次退了两步——同一段代码同一个模型,步数都不一样。换个温度、换句用户输入,它可能第八次还在敲同一扇门。每一圈都是真金白银的 API 调用。
所以 maxSteps 该写还得写。
打个比方。
实习生做一步回来问你一次,这是好事,说明他不瞎干。
但你得提前说清楚:「问到第八次还没头绪,就别问了,回来跟我讲讲卡在哪。」不然他能一天来敲三十次门。
💡 别走极端:maxSteps 设成 3,稍微复杂点的任务永远做不完,而且失败得莫名其妙。先从 8-10 起步,把实际步数打进日志,跑一周再按真实分布调。
六、前端的错误直觉 → 工具错误回灌:这一组是反着来的
前面五组都是「换了个名字而已」。这一组不是。这一组我按 React 的直觉写,写错了。
📌 先看个场景。
用户说「帮我查下订单 10086」——少了个 A,格式不对。
前端遇到错误的肌肉记忆是什么?兜住,别扩散。 Error Boundary 就是干这个的:一个组件炸了,边界把它接住,页面其他部分照常。
(严格说 Error Boundary 管的是渲染期抛出的异常,订单号格式错属于业务校验,两者不是一类东西。我借的不是它的职责,是它训练出来的那个直觉——而这个直觉,正是下面要被推翻的。)
于是我在 Agent 里也这么写:工具报错 → catch 住 → 返回一句「查询失败,请稍后重试」。
但问题来了。
这么一写,模型永远学不会。它不知道自己错在哪,只知道「失败了」,于是要么原样再试一次,要么直接放弃。
正解是反过来的:把一条脱敏过、可操作的错误原因,明明白白交给模型。
而这里有个我第一版没做到的区分——错误分两类,一类能说,一类不能:
tsexport async function runTool(tools, call) {
const tool = tools[call.function.name];
if (!tool) return { error: "UNKNOWN_TOOL", message: `没有名为 ${call.function.name} 的工具` };
let raw;
try { raw = JSON.parse(call.function.arguments); }
catch { return { error: "BAD_ARGUMENTS", message: "参数不是合法 JSON,请重新生成" }; }
// ① 合法 JSON ≠ 合法参数。别让模型的输出直接进业务函数
const checked = tool.validate(raw); // 真实项目用 Zod / Ajv
if (!checked.ok) {
// 校验文案是我自己写的,可以放心回灌
return { error: "INVALID_ARGUMENTS", message: checked.issues.join(";") };
}
try {
return await tool.impl(checked.data);
} catch (err) {
// ② 原始 err 可能带内网地址、SQL、账号 —— 只进日志
logger.error({ tool: call.function.name, err });
// 给模型的是提前写好的白名单文案
return { error: "TOOL_FAILED", message: tool.fallbackMessage };
}
}第一版我这儿写的是 message: err.message,还在文章里吹「已脱敏」。真实的 err.message 长什么样?我塞了个必抛异常的库存工具进去,跑给你看:
code日志里(stderr):
[tool failed] check_stock Error: connect ETIMEDOUT
inventory-db.internal:5432 user=svc_stock
模型和用户看到的:
库存查询暂时不可用,请稍后再试。内网主机名、端口、服务账号,err.message 一个不落全带着。而模型是会把上下文里的东西说出来的。 这要是直接回灌,等于把内网拓扑写进了客服话术。
堆栈进日志,人话进 messages——两个出口,别混。
再看正解跑起来的样子。订单号格式错那次:
code工具调用轨迹 : get_order
步数 : 2 圈
最终回答 : 订单号格式有误,正确格式是字母 A 加 5 位数字,例如 A10086。请确认后重试。两圈搞定,而且它把「正确格式是什么」原话转达给了用户。这句话是我写在校验器里的——我怎么写的错误,它就怎么帮我传下去。
所以在 Agent 里,错误信息不再只是给运维看的日志,它同时是给模型看的 prompt。"查询失败" 和 "订单号应该是字母 A 加 5 位数字",对人来说都是报错,对模型来说是天壤之别。
打个比方。
快递小哥送件回来说地址不对。
你要是只说「送失败了,再送一次」,他还是照着那个错地址跑。
你得说「这个地址缺门牌号,你打电话问收件人要一下」——他才知道下一步干嘛。
但你不会把整个仓储系统的后台密码一起塞给他。
💡 别走极端:参数校验的错误可以原样回灌,运行时异常必须换成白名单文案。这两者写在同一个 catch 里,是最容易出事的地方。
七、状态提升 → 多 Agent:别一上来就分家
任务变复杂了:既要查订单,又要查库存,还要写道歉邮件。你在网上刷到 Multi-Agent 架构,Supervisor 底下挂三个 Worker,图画得特别帅。
React 早就教过你这一课:两个组件要共享一个值,才把 state 提升到共同的父节点。要是啥都往根组件提,最后你会得到一个八百行的 App.tsx。
Agent 这边同理:一个 Agent 加三个工具能干完的活,不要拆成三个 Agent。 拆的理由该是「它们需要不同的 system prompt、不同的工具集、不同的权限」,不是「听起来比较高级」。
打个比方:三个人的活,你非要设两层组长。开会时间比干活时间长,而且每传一道手,信息就掉一点。
💡 别走极端:一个 Agent 什么都往里塞同样会出问题。但别拿工具数量当阈值——「超过十个就该拆」这种话我一开始也想写,写之前发现自己一个数据都没有,就删了。该看的是调用轨迹:模型开始频繁选错工具、两个工具的描述你自己都分不清,那才是信号。
八、流式渲染 → 流式输出:这一组你不是「会」,是「比别人强」
前面七组讲的都是「你已经会了」。这一组不一样——这是你的优势区。
模型的输出不是一次性给你的,是一串增量事件持续推过来的。一个事件里可能是一个字,也可能是一整句,工具参数还会被拆成好几段慢慢拼。网络事件和 token 并不一一对应,这是接流式第一次都会踩的坑。
对习惯了 request/response 的后端同学来说,这是全新的心智模型:一个还没结束的响应怎么处理?中途断了怎么办?用户点了「停止」,已经发出去的部分算什么?拼到一半的工具参数,是丢掉还是缓存?
而你——
jsconst reader = res.body.getReader();这行你写了多少遍了。ReadableStream、AbortController、SSE、断线重连、打字机效果、流到一半接口挂了要不要保留已渲染的部分……这些是你的日常,是别人的新大陆。
打个比方:上菜一道一道端,客人第一道就能动筷子;等一小时全上齐,菜是齐了,人也饿走了。
怎么让客人在第三道菜等太久的时候不掀桌——这个手艺,前端练了十年了。
💡 这一节没有「别走极端」,只有一句:面试聊到流式处理的时候,别谦虚。
说到底,是同一件事
八组写完,它们指向同一句话:
Agent 开发的难点在应用层,不在模型层。
你不需要懂 Transformer 的数学,就像你不需要会写渲染引擎也能做好前端。你需要的是:怎么管状态、怎么隔离副作用、怎么划清边界、怎么让一个不确定的系统给出确定的结果、怎么在出错时优雅降级。
这些恰好就是前端这些年一直在解决的问题。换个说法可能更清楚——
你不是在学一门新语言。你是在给一个新用户写界面,只不过这个用户不是人,是模型。
它有它的屏幕尺寸(上下文窗口),有它的输入控件(工具声明),有它的渲染循环(Agent Loop),有它的异常态(工具报错)。你要做的还是老本行:把一个复杂、易变、经常不按你预期来的东西,包装成别人用起来不会崩的界面。
我共事过最好的那批工程师,从来不是学新东西最快的。他们是最快认出「这个新东西其实是我见过的那个老东西」的。名词换了一轮又一轮,底下的问题就那么几个。
新技术真正筛掉的,从来不是「没学过的人」,是「不敢承认自己其实已经学过一半的人」。
动手前的 10 秒自检清单
存图,下次写 Agent 之前扫一眼:
- 模型不记得任何东西——这一轮要用的信息,在
messages里吗? - 记忆分层了吗?工作记忆(system)/ 短期(最近几轮)/ 长期(外部存储),别混成一锅。
- 工具是你执行的,不是模型执行的。拿到
tool_calls之后,那行impl(args)你写了吗? - 上下文有预算吗?跑一次八轮对话,把
prompt_tokens打出来看它是不是一路往上爬。 - 裁剪按「轮」切了吗?
tool_calls和它对应的全部 tool 结果,有没有被切散?**接口不会替你拦这个。 json_object只保证是合法 JSON。能上json_schema就上,上不了就在 prompt 里把类型写死。- 代码侧还有一层校验吗(Zod / Ajv)?schema 管形状,管不了业务规则。
- **工具声明每一圈都随请求发出去了吗?**定义了不等于模型知道。
- **模型给的参数,进业务函数之前校验了吗?**合法 JSON 不等于合法参数。
- 回灌给模型的错误里,有没有夹带内网地址、SQL、账号、堆栈?
最值钱的是第 6 条和第 10 条。第 6 条是我这次实测里最反常识的一条——json_object 开了照样给你 "high",而真正管住形状的 json_schema 大部分教程压根没提;第 10 条是我自己翻车的地方,一句 err.message 就能把内网主机名送到客服话术里。
💬 聊聊
我以前也是那个抱着 LangChain 文档从第一页啃的人,啃了三个月,写出来的东西还不如后来照着 messages 数组手搓的那版清楚。
我是阿森,专注前端技术干货分享。觉得有用的话,点个「在看」让更多前端同行看到。
也特别建议转发给那位天天在群里说「前端要完了」的兄弟——顺便告诉他,第八组那节,说的就是他。
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

