本篇是《智能体演化史》系列第 2 篇 系列主线不是"某年出现了某技术",而是"因为上一步崩了,所以下一步才出现"。 全系列共用一个从 34 行长到 600 行的 tiny-agent,本篇是它的 v0.2。
怎么读这篇
- 🚶 12 分钟版:只看「崩坏现场」「一句话原理」和泳道图,搞清申请书机制。
- 🏃 35 分钟版(推荐):加读三范式对照、申请书字段拆解和 v0.2 代码,你会知道工具该怎么写、写多少。
- 🤿 深潜版:顺着 token 测算数据往下想:为什么工具变多之后,事情会失控。
01 · 崩坏现场:它给了个很像样的数字,但那是编的
回到 2023 年初。你想让 GPT 告诉你北京今天的天气。
你试了当时最通行的办法——在提示词里恳求它:
"请查询北京今天的天气。
请务必只返回 JSON,格式如下:{"city": "…", "temp": 数字, "rain": true/false}
不要输出任何其他文字。"
它照做了。返回 {"city": "北京", "temp": 18, "rain": true}。看起来完美。
问题在于:这个 18 度是它编的。
它没查过任何天气 API,它只是根据训练数据里"北京十月大概十几度"的印象,生成了一个格式正确、数值合理的答案。更要命的是——你从返回结果上完全无法区分「真的查了」和「编得像」。
这就是纯语言模型的根本困境:它是一台只会输出文字的机器,而文字和现实之间没有必然联系。
而且就算它真的想告诉你"请帮我调一下天气 API",它也没有嘴把这个请求说出来——它只能继续输出文字。于是整个 2023 年上半年,工程师们都在跟三件事搏斗:
- 格式不稳定 —— 有时返回 JSON,有时在 JSON 外面套一句"好的,这是结果:"
- 参数瞎编 —— 你定义了 max_results 必须是 1-10 的整数,它给你返回 "十个"
- 幻觉执行 —— 它声称"我已经发送邮件了",而实际上它根本没有发邮件的能力
解法不是"把提示词写得更好",而是承认一件事:模型不应该被要求执行,它应该被要求申请。
02 · 一句话原理
工具调用不是模型在用工具,而是模型在生成一份格式严格的调用申请书; 真正的执行,发生在模型之外的你的代码里。
这句话听起来像文字游戏,但它是整个 Agent 工具生态的地基。理解它,后面所有事情(包括 MCP、工具权限、“为什么工具不能随便给”)都能一次想通。
03 · 一次完整往返:谁在写字,谁在干活
你的代码(真正干活)
模型(只会写字)
你的代码(真正干活)
模型(只会写字)
#mermaid-svg-5D9GUZfM6QF3nGVK{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5D9GUZfM6QF3nGVK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5D9GUZfM6QF3nGVK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5D9GUZfM6QF3nGVK .error-icon{fill:#552222;}#mermaid-svg-5D9GUZfM6QF3nGVK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5D9GUZfM6QF3nGVK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5D9GUZfM6QF3nGVK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5D9GUZfM6QF3nGVK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5D9GUZfM6QF3nGVK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5D9GUZfM6QF3nGVK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5D9GUZfM6QF3nGVK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5D9GUZfM6QF3nGVK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5D9GUZfM6QF3nGVK .marker.cross{stroke:#333333;}#mermaid-svg-5D9GUZfM6QF3nGVK svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5D9GUZfM6QF3nGVK p{margin:0;}#mermaid-svg-5D9GUZfM6QF3nGVK .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5D9GUZfM6QF3nGVK text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5D9GUZfM6QF3nGVK .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5D9GUZfM6QF3nGVK .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5D9GUZfM6QF3nGVK .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5D9GUZfM6QF3nGVK .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5D9GUZfM6QF3nGVK #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5D9GUZfM6QF3nGVK .sequenceNumber{fill:white;}#mermaid-svg-5D9GUZfM6QF3nGVK #sequencenumber{fill:#333;}#mermaid-svg-5D9GUZfM6QF3nGVK #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5D9GUZfM6QF3nGVK .messageText{fill:#333;stroke:none;}#mermaid-svg-5D9GUZfM6QF3nGVK .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5D9GUZfM6QF3nGVK .labelText,#mermaid-svg-5D9GUZfM6QF3nGVK .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5D9GUZfM6QF3nGVK .loopText,#mermaid-svg-5D9GUZfM6QF3nGVK .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5D9GUZfM6QF3nGVK .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5D9GUZfM6QF3nGVK .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5D9GUZfM6QF3nGVK .noteText,#mermaid-svg-5D9GUZfM6QF3nGVK .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5D9GUZfM6QF3nGVK .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5D9GUZfM6QF3nGVK .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5D9GUZfM6QF3nGVK .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5D9GUZfM6QF3nGVK .actorPopupMenu{position:absolute;}#mermaid-svg-5D9GUZfM6QF3nGVK .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-5D9GUZfM6QF3nGVK .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5D9GUZfM6QF3nGVK .actor-man circle,#mermaid-svg-5D9GUZfM6QF3nGVK line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5D9GUZfM6QF3nGVK :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
① 写出申请书 tool_call JSON
② 解析并真正执行 get_weather("北京")
③ 拿到真实结果 {"rain": true}
④ 结果回填(role="tool" 消息)
带着结果,重新思考(下一轮)
这个分离带来三个直接后果,每一个都很重要:
04 · 三条路:从"求它"到"它申请"
把 2023 到 2026 的工具接入方式排成一列,你会看到一条清晰的收敛路线:
工具接入的四次演进。关键转折在第三个:从此不再是"你求它按格式输出",而是你把工具清单交给它,它自己决定要不要用、用哪个。这个"自己决定",就是 Agent 的起点。
| 文本约定 | 2023 之前 | 提示词里写"请返回 JSON" | 低(经常跑偏) | 人(写在 prompt 里) | 基本淘汰 |
| 结构化输出 | 2023 上半年 | 约束解码 / JSON mode | 高(语法层保证) | 人 | 仍在用,用于抽取 |
| 原生 FC | 2023.06 起 | tools=[…] 入参 | 高(schema 约束) | 模型自己 | 主流 |
| MCP | 2024.11 起 | 标准协议挂载工具服务 | 高 | 模型自己 | 快速增长 |
看倒数第二列:前面两种方式,用不用工具是你写在提示词里的——本质还是 workflow。第三种开始,决定权交给了模型。这一交,才有了 Agent。
为什么格式不能靠"求"? 因为约束必须发生在模型无法违反的层面。提示词是建议,解码约束是规则,schema 是契约。工程上凡是依赖"模型听话"的设计,都会在某一天失效——差别只是哪一天。
时间点为各能力的公开发布时间,厂商实现细节存在差异。
05 · 拆开申请书:它到底写了什么
模型想用工具时,输出的不是普通文本,而是一个结构化的 tool_calls 数组:
{
"id": "call_aB3dEf",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\\"city\\": \\"北京\\"}"
}
}
申请书字段拆解
| id | 这次调用的唯一编号 | 回填结果时必须原样带回,否则模型对不上号——多工具并行时尤其容易错 |
| function.name | 它想调的工具名 | 可能是你没定义过的名字(幻觉调用),必须走兜底分支 |
| function.arguments | 参数 | 是 JSON 字符串不是对象,必须 json.loads;可能缺字段、类型错、值越界 |
| (隐含) | 它可能一次要调多个 | tool_calls 是数组,要循环处理;并行调用的顺序不保证 |
最阴的是前两个坑:你得假设模型给你的申请书是"不可信输入"。 就像处理用户表单一样——你不会把用户 POST 过来的东西直接 eval(),也不该把模型给的参数直接 ** 展开就扔进函数。
06 · 最小实现:v0.2,三只手加一张分发台
版本 diff: v0.1 → v0.2 · 34 行 → 46 行
- 工具从 1 个加到 3 个(run_python / read_file / search_web)
- 新增 DISPATCH 分发表:把工具名映射到真实函数
- 新增未知工具兜底:模型写出不存在的工具名时,拒绝而不是崩溃
# tiny-agent v0.2 —— 给它三只手,外加一张"申请书"处理台
import os, json, subprocess
from openai import OpenAI
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
# ③ 工具:三只手,每只都附一份"使用说明书"(schema)
TOOLS = [
{"type": "function", "function": {"name": "run_python", "description": "执行一段 Python 代码并返回 stdout", "parameters": {"type": "object", "properties": {"code": {"type": "string", "description": "要执行的 Python 代码"}}, "required": ["code"]}}},
{"type": "function", "function": {"name": "read_file", "description": "读取本地文本文件内容", "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "文件绝对路径"}}, "required": ["path"]}}},
{"type": "function", "function": {"name": "search_web", "description": "联网搜索并返回结果摘要", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "搜索关键词"}, "n": {"type": "integer", "description": "返回条数,1-10"}}, "required": ["query"]}}},
]
def run_python(code):
r = subprocess.run(["python", "-c", code], capture_output=True, text=True, timeout=10)
return (r.stdout or r.stderr)[–2000:]
def read_file(path):
return open(path, encoding="utf-8").read()[:4000]
def search_web(query, n=5):
return json.dumps({"results": [f"{query} 的第 {i+1} 条结果" for i in range(n)]}, ensure_ascii=False)
# ★ 关键:真正动手的是这张表,模型只负责写申请书
DISPATCH = {"run_python": run_python, "read_file": read_file, "search_web": search_web}
GOAL = "看看当前目录下有哪些 .py 文件,数一数一共多少行"
MAX_STEPS = 8
messages = [{"role": "user", "content": GOAL}]
for step in range(MAX_STEPS):
resp = client.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=TOOLS)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
print(f"[step {step+1}] 最终回答 -> {msg.content}")
break
for c in msg.tool_calls:
fn = DISPATCH.get(c.function.name)
args = json.loads(c.function.arguments)
# ★ 模型可能写出一张"不存在工具的申请书",守门的是你的代码
result = fn(**args) if fn else f"[拒绝] 没有名为 {c.function.name} 的工具"
print(f"[step {step+1}] {c.function.name}({args}) -> {str(result)[:100]}")
messages.append({"role": "tool", "tool_call_id": c.id, "content": str(result)})
整段代码真正体现"申请书"机制的,是这两行:
fn = DISPATCH.get(c.function.name) # 查表:它想调谁?真有这个人吗?
result = fn(**args) if fn else f"[拒绝] 没有名为 {c.function.name} 的工具" # 有就干,没有就拒
模型输出的工具名,只是一根用来查表的字符串。 你在 DISPATCH 里放什么,它才有什么;它写一个不存在的名字,你能拦得住。这就是"申请书"三个字的全部含义。
顺带说一句:run_python 这个工具等于给模型开了一个 shell。如果你真把它用在生产环境,必须加沙箱(超时、内存限制、禁用网络、只读挂载)。这是 P8 的重点。
07 · 一个隐患:工具越多,每一轮越贵
现在有了申请书机制,一个自然想法是:那我多给点工具不就好了?
慢着。工具清单(schema)是要塞进每一轮请求里的——它不是发一次就完了,而是循环跑多少轮,就重复发送多少遍。
按两类真实 schema 实测(脚本可复现):
| 1 | 74 | 233 | 4,660 |
| 5 | 370 | 1,165 | 23,300 |
| 10 | 740 | 2,330 | 46,600 |
| 20 | 1,480 | 4,660 | 93,200 |
| 30 | 2,220 | 6,990 | 139,800 |
| 50 | 3,700 | 11,650 | 233,000 |
两条线都随工具数线性增长,但详细型的斜率是精简型的 3 倍多。更关键的是:这是每一轮都要付的成本——跑 20 轮,就要付 20 次。
50 个详细型工具,跑 20 轮循环 = 233,000 token。 这个数字已经超过 128K 上下文窗口的一半,而这 23 万 token 里一个字都不是任务内容,全是重复发送的工具说明书。这就是后来"工具太多导致认知过载"的量化形态——也是 P5(拆智能体)和 P6(MCP 延迟绑定)要解决的核心问题。
所以工具不是越多越好,这里有条经验线:单个 Agent 挂载 5-15 个工具比较健康,超过 20 个就要考虑拆了。
08 · 现实对照:高管与助理
申请书机制在现实里有极其精确的对应:一个只会签字的高管,和一个负责跑腿的助理。
| 工具 schema | 助理递上来的标准申请单模板 | 格式固定,才知道该填什么 |
| tool_call | 高管在申请单上签字批准 | 他批的是"去办这件事",不是自己去办 |
| DISPATCH 表 | 助理拿着批条去跑腿 | 真正打车、订酒店、发快递的是助理 |
| 结果回填 | 助理回来汇报结果 | 高管看到结果,才能决定下一步 |
| 未知工具被拒 | 申请单上写了个公司不存在的部门 | 行政直接打回,而不是硬着头皮去找 |
这个类比能解释一个设计困惑:为什么不让模型直接执行?
因为高管自己去跑腿,公司就乱了。让签字权和执行权分离,你才能:
- 审计 —— 所有申请单都留档,谁批的、批了什么、花了多少
- 拦截 —— 申请单上写了"转 100 万",行政可以说这个要二级审批
- 替换 —— 换个助理(换个实现),高管的签字方式不用变
- 限权 —— 助理有门禁卡,高管没有,也不需要
这四条在 Agent 工程里都有对应物:日志、高危操作人工审批、工具实现热替换、最小权限沙箱。它们全都建立在"模型不执行"这个前提上。
反过来说,如果你看到某个方案宣称"让模型直接操作数据库"——不用看了,它放弃了上面全部四条。
09 · 下一篇:它有手了,但只会用一次
v0.2 里的 MAX_STEPS = 8 已经悄悄埋了个循环。但请注意一个尴尬的事实:模型每一轮只能看到"上一轮的申请书 + 上一轮的结果",它并没有在"推进"一个任务。
下一篇,我们让这个循环真正转起来:P3《while True 的威力与恐怖》。你会看到 ReAct 闭环怎么把一问一答变成持续行动,以及——为什么 2023 年那批 Agent 会烧掉几百美元、跑几个小时,最后交出一份空白。同时我们会算出一条很少有人画出来的曲线:步数涨 3 倍,token 涨 9 倍。
思考题:你的 Agent 现在挂了几个工具?如果把工具描述写得再详细一倍(为了让它用得更准),你的每轮成本会涨多少?这笔账值得吗?
欢迎在评论区聊聊。
系列导航
| P1 | Agent演化史(一)剥掉营销话术后,智能体只剩 34 行代码 | 已发布 |
| P2 | 模型从没碰过你的工具:Function Calling 只是一份申请书 | 本篇 |
本篇资产
- tiny-agent-v0.2.py —— 46 行,三工具 + 分发台,可直接改造自用
- calc_metrics.py —— 本篇 token 测算脚本,参数可改,结果可复现
- 测算口径:JSON 紧凑序列化后按 4 英文字符 ≈ 1 token
本文为盘子工具站生态原创技术专题系列,转载请注明出处。 
配套代码tiny-agent-v0.2.py(36 行),可以在资料下载处直接下载
网硕互联帮助中心




评论前必须登录!
注册