《我和AI的28天》Day 3-6:让AI学会点菜,我学会了不被它骗
系列:28天,一个人+一个AI助手,从零到大模型API实战,再造一个能上线的AI产品。 本集:Function Calling、多轮工具链、上下文管理、错误处理------助手从"会说"到"会做"的四天。
缘起回顾
上一篇(Day 1-2)的结尾,我的 WMS 助手赢了一场评测:10/10,域外问题老老实实拒答。
但它只会说,不会做。
用户问:"矿泉水还有多少库存?"
它张口就编:"矿泉水库存约500件。"
------约字用得情真意切,数字纯属虚构。大模型的自信和它的知识是两套独立的系统,这话术像极了面试吹牛的人。
这四天(Day 3-6),就是让它从"编数字"进化到"查真数",顺便让我吃到了四个货真价实的坑。
上一篇的主角是评测集------这一篇它继续登场:每次踩坑的修复,最后都沉淀成它的新用例。坑越踩越多,网越织越密。
Day 3:Function Calling------模型是个只点菜的客人
Function Calling 的原理一句话就能说清:
模型没有手。 你的数据库、你的函数都在你自己电脑里,模型碰不到。它唯一能做的是输出一段结构化 JSON,说"我想调 query_stock,参数是矿泉水"------点菜。炒菜(执行函数)、把菜端回去(结果回填),全是你的代码的事。
text
用户: 矿泉水还有多少?
模型: (输出JSON)我想调 query_stock({"product_name": "矿泉水"})
你的代码: 执行函数 → {"product": "矿泉水", "stock": 450}
模型: (看到结果)矿泉水当前库存450件。
理解了"点菜-炒菜-端菜"这个分工,90% 的 Function Calling 疑问都消失了。剩下的 10%,是我踩的坑。
坑1:空返回之谜
写完代码一跑,模型有时返回空。查了半天 prompt、换了模型,都无效。
最后逐项排查发现:我给请求同时设了 response_format={"type":"json_object"} 和 tools。两个"必须输出JSON"的指令打架了------一个说"按 JSON 模式输出",一个说"按工具调用格式输出",模型精神分裂,干脆闭嘴。
修复:带 tools 的对话里,删掉 response_format。需要结构化输出时,二选一。
这条坑指南上基本搜不到,因为大多数人不会同时用两个。但当你从"json模式"改造成"工具模式"的途中,它就会咬你一口。
坑2:schema 写糊,模型就选糊
我写了个查询库存的工具,description 只有四个字:"查询库存"。
然后模型开始自由发挥:
- 用户问"库存最多的商品是什么"------它调了查询库存的工具,但这个工具根本不支持排序,返回了单个商品的数字。模型拿着这个数字硬答,答非所问。
- 用户问"帮我调拨库存到2号仓"------它居然试图用查询工具来执行调拨。
修复的过程让我总结出四条军规:
- description 写"何时调我",并写清边界 ------"查询指定商品在指定仓库的当前库存。当用户询问库存、还剩多少时调用。不支持排序、排行、写操作。"(最后那句是修上面两个误判的关键)
- 参数给格式示例 + 缺省行为------"仓库编号,格式如WH001。用户未指定时传'all'查全部合计"
- 参数名用自然语言直译 ------
product_name,不是prd - 返回值带上下文 ------返回
{"product": "矿泉水", "stock": 450, "unit": "件"},不是裸的450。模型是"读结果写作文",结果里没上下文,作文就靠编
改完重跑测试集:误判全部消失。顺手把这两条误判场景固化成新用例------上一篇建立的评测集,从这一篇开始不再只测 prompt,开始测工具:每个新工具上线,先过一遍"正常调用 / 边界 / 乱传参"三件套。
这一段的教训可以浓缩成一句话:
工具描述是给模型看的 API 文档。文档含糊,调用者就会"合理地"用错------人如此,模型也如此。
Day 4:多轮工具链------一次对话,多次点菜
单轮工具调用跑通后,我信心满满地出了道综合题:
"compare 矿泉水和纸箱的库存"
FAIL。
看日志:模型只调了一次 query_stock(查了矿泉水),然后直接开答,纸箱像是不存在。
根因:我的代码结构就不支持二次点菜
我当时的写法是"调一次工具 → 再请求一次 → 完事"。模型的多个工具调用需要循环处理------每轮回填结果后重新请求,直到模型不再要工具。
改成 while 循环:
python
for round_i in range(max_rounds): # 保险丝防死循环
resp = client.chat.completions.create(
model="glm-4-flash", messages=messages, tools=tools)
msg = resp.choices[0].message
if not msg.tool_calls: # 不再点菜 = 给出最终回答
return msg.content
messages.append(msg) # 记录模型的点菜
for tc in msg.tool_calls:
try:
args = json.loads(tc.function.arguments)
result = FUNCS[tc.function.name](**args)
content = json.dumps(result, ensure_ascii=False)
except Exception as e:
content = json.dumps({"error": str(e)}) # 报错也要回填!
messages.append({"role": "tool",
"tool_call_id": tc.id, "content": content})
三个关键点,每个都是踩出来的:
- 每轮重发完整 messages------模型是无状态的,不重发历史它就"失忆"
- 工具报错也回填------错误传给模型,它会自己换参数重试或向用户解释;直接崩掉,一次可自动恢复的失败变成体验事故
- max_rounds 保险丝------防模型陷入"查了又查"死循环
顺带留意一个细节:循环里我们成对地追加消息------模型的点菜(assistant)和炒好的菜(tool)永远一起进历史,谁也不落下谁。当时我只当这是天经地义,没想到这个"成对"的规矩,第二天会用一记 400 教育我。
改完再跑 compare 题:
text
🔧 第1轮 query_stock({"product_name": "矿泉水", "warehouse_id": "all"})
🔧 第2轮 query_stock({"product_name": "纸箱", "warehouse_id": "all"})
回答: 矿泉水总库存1540件,纸箱无库存记录。
模型自己规划了"查A → 查B → 对比总结"的三步链。这一刻它不再是查询机,是个小 Agent 了。
附加坑:拆包拆出的 ERROR
给评测集加"仓库容量"新工具时,跑出一个古怪报错:
not enough values to unpack (expected 3, got 2)
我的函数返回值改成 dict 之前,调用处还在用 used, total, remaining = 函数返回值 拆包------函数只返回了俩。
顺手改进:返回值带上 usage_rate(算好的使用率百分比)。模型拿到现成字段,不用自己心算,回答爆仓问题的准确率肉眼可见地提升。
给模型的结果里,每多一个现成字段,少一次胡算的机会。
Day 5:上下文------甜蜜的麻烦
多轮链跑通后新问题立刻出现:token 账单在涨。
每轮都重发完整 messages,聊20轮就是20份历史;再加上工具 schema 本身每轮几百 token 的"固定税"。demo 无所谓,上线就是成本事故。
滑动窗口截断------和一个协议冷知识
主流方案是滑窗:只保留 system + 最近 N 轮。
但我第一次截断就翻车了,报错 tool_call_id not found------Day 4 那个"点菜和上菜必须成对"的规矩,原来是写进协议里的铁律,而我只在追加时守了它,截断时忘了它:
assistant(tool_calls) 和它后面的 tool 消息是"点菜-上菜"的原子组,协议要求必须成对存在。 只砍一头,API 直接 400 拒收------不是模型会困惑,是合同不成立。
所以截断逻辑必须以"轮"为单位,点菜和上菜同进同出。
会话记忆:一句话的胜利
加上 session 级的历史存储后,测试了最经典的指代消解:
第一轮:"WH001还有多少空位?" → 答:2500托盘位,使用率50% 第二轮:"它 的利用率呢?" → 答:"WH001的利用率为50%" ✓
"它"字被正确解析成 WH001,靠的就是上一轮的历史。孤立的对话里,这句话是无解的。
会话记忆不是高级功能,是理解人类语言的前提------人说话本来就充满指代。
Day 6:让调用层打不垮
前五天的代码都是"晴天代码":网络正常时一切美好。Day 6 处理暴雨。
四类故障,四种姿势,核心判断只有一个------这个错误重试有意义吗?
| 故障 | 重试? | 原因 |
|---|---|---|
| 超时/断连 | ✅ | 请求没送达,下次可能就好 |
| 429 限流 | ✅ 等 Retry-After | "现在不行"不等于"你错了" |
| 5xx | ✅ 退避 | 服务端的事,换个时间窗 |
| 400/401 | ❌ | 请求本身错了,重发一万次也一样 |
指数退避的实现不难,难的是三个细节:指数 (摊开重试时间给服务端喘息)、jitter (防所有客户端节拍器式齐射)、上限(重试是保险丝不是永动机)。
Day 7 整合验收时,我特意在对话中途注入了2次断网:
text
[RETRY] APIConnectionError: 第1次失败,1.3s 后重试
[RETRY] APIConnectionError: 第2次失败,2.0s 后重试
用户: WH002还有多少空位?
助手: WH002仓库剩余空位为900托盘位,使用率为70%...
>>> 重试层自动恢复,用户无感
同样的网络抖动,三天前的代码会直接把 Traceback 糊用户脸上。
用户不应该为基础设施的抖动买单------这不是功能,是尊严。
评测集这四天长了什么样
口号不值钱,长出来的用例才值钱。四天里,它具体追加了这些:
- Day 3 加:误调用例("库存最多的是什么"------不该调查询工具,应拒答)
- Day 4 加:多轮链用例(compare 题------必须连续调两次工具并汇总对比)
- Day 5 加:指代消解用例("它的利用率呢"------必须靠会话历史解析"它")
- Day 6 加:故障注入用例(中途断网 2 次------回答必须正常,不许崩)
每次改完全量回归一遍:老用例没坏,新用例全过,才算真的修好了。上一篇的主角活到了这一篇,而且长成了骨架。
本集领悟
- 模型只会点菜,执行在你的代码里。 想清楚"谁有手",90% 的 Function Calling 疑问消失。
- 工具描述是给模型看的 API 文档。 文档含糊,调用者就会合理地用错。四条军规比换模型管用。
- 工具报错也回填。 把错误给模型看,它有概率自我修复;把错误给用户看,你只是选择了最快的失败方式。
- 评测集是项目的锚。 prompt 时代它防乱答,工具时代它防乱调。验收题沉淀成新用例,每次改完全量回归------分数不会说谎。
- 数学不是模型的强项,架构才是。 判断红线(如94%爆仓线)交给代码算好字段,别赌模型心算。
- 晴天写代码,雨天才知道谁在裸奔。 重试和兜底不是"以后再加",是调用层的骨头。
Day 3-6 资产清单
- 可复用代码:多轮工具链模板(while+回填+保险丝)/ 原子组安全的截断函数 / 会话管理器 / 指数退避重试层
- 踩过的坑:response_format 与 tools 冲突空返回 / schema 含糊致误判 / 拆包 ERROR / 截断拆散原子组报400
- 评测集:8+1 条用例,且验收题会沉淀进测试集------评测集自己会成长
下集预告
助手现在能查数据、会推理、打不垮了。
但它只认识我"喂"过它的东西。用户问"收货的操作流程是什么"------系统手册里明明写着,模型却开始自由发挥。
下一集:RAG------给助手装上"读文档"的眼睛。这也是《28天》系列我最期待的一篇。
这是《我和AI的28天》第 2 篇。系列讲一个人+一个AI助手,28天从零到大模型API实战,再造一个能上线的AI产品。前篇:Day 1-2:一个评测分数,从50%打到100%