📑 本页目录(点开跳转)
06 · 类型标注是给工具看的
⏱ 54 分钟 | ⭐ 标注在运行时一个字都不检查 —— 它全部的价值是「能被程序读出来」
🎯 一句话
Python 的类型标注不是给解释器看的,是给读它的程序看的。
@tool 能从函数自动生成 JSON schema、FastAPI 能自动校验请求体,靠的都是同一件事:
标注被存在一个普通字典里,任何代码都能把它读出来。理解这一点,你就知道该在哪标、标了能换来什么、以及标了之后仍然不会有人替你检查。
🧩 一、先把最反直觉的一条钉死:写错类型照跑
# ann_runtime.py —— 标注在运行时不做任何检查
def add(a: int, b: int) -> int:
return a + b
print(add("你好", "世界")) # ⭐ 传两个字符串,一句警告都没有
print(add.__annotations__) # 标注只是存在这个字典里
add.__annotations__["a"] = "随便写点什么" # ⭐ 还能改
print(add.__annotations__)
print(add(1, 2)) # 改完照样跑
实跑输出:
要点
你好世界
{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
{'a': '随便写点什么', 'b': <class 'int'>, 'return': <class 'int'>}
3
⚠️ 第一行是 你好世界 —— 标注说要 int,传进去两个 str,+ 变成了字符串拼接,函数返回了一个 str,
而标注写着 -> int。没有任何东西阻止它,也没有任何东西提醒你。
⭐ 记住这个心智模型:
def add(a: int)里的int和a = 3里的3地位差不多 —— 它是一个被求值的表达式,结果被塞进函数.__annotations__这个字典,然后就没有然后了。 解释器不看它,if不看它,+不看它。看它的是别人写的程序。
💀 这条最贵的实战形态是静默类型污染:
# silent_str.py —— 标注说 int,实际收到 str,结果是一个合法但错误的值
def plan(prompt: str, max_tokens: int = 256):
return max_tokens * 2 # ⭐ 期待 512,实际拿到 "512512"
print(plan("hi", "512"))
输出是 512512。⚠️ 不是报错,是一个类型不对但看起来像数字的字符串,
它会一路流到配置里、流到请求体里,直到很远的地方才炸 —— 那时 traceback 指的已经不是这里了
(怎么从那种 traceback 倒推回来,见 07 章)。
🧩 二、把标注读出来:这就是 @tool 的全部魔法
《智能体工程教程》的挑战项目 A 里有一条 90 分钟的验收项:
@tool装饰器:读函数的类型注解和 docstring,自动生成{name, description, parameters}schema。
它听起来像黑魔法,其实只有两个函数:inspect.signature(拿参数名和默认值)+
typing.get_type_hints(拿解析好的类型)。下面这段 30 行就是一个能跑的最小实现:
# mini_tool.py —— @tool 的全部魔法:把签名和 docstring 读成 JSON schema
import inspect
import json
import typing
PY2JSON = {int: "integer", float: "number", str: "string", bool: "boolean"}
def tool(fn):
sig = inspect.signature(fn) # ⭐ 参数名、顺序、默认值
hints = typing.get_type_hints(fn) # ⭐ 解析好的类型对象
props, required = {}, []
for name, p in sig.parameters.items():
props[name] = {"type": PY2JSON.get(hints.get(name), "string")}
if p.default is inspect.Parameter.empty:
required.append(name) # ⭐ 没有默认值 = 必填
else:
props[name]["default"] = p.default
fn._schema = {
"name": fn.__name__,
"description": (fn.__doc__ or "").strip().splitlines()[0], # ⭐ docstring 第一行
"parameters": {"type": "object", "properties": props, "required": required},
}
return fn
@tool
def search(query: str, top_k: int = 5) -> str:
"""在知识库里检索,返回最相关的若干段落。
第二行往后是给人看的,schema 里没用上。
"""
return f"搜到 {top_k} 条关于「{query}」的结果"
print(json.dumps(search._schema, ensure_ascii=False, indent=2))
print(search("向量检索"))
实跑输出(截取 schema 部分):
对照
{
"name": "search",
"description": "在知识库里检索,返回最相关的若干段落。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"top_k": {"type": "integer", "default": 5}
},
"required": ["query"]
}
}
搜到 5 条关于「向量检索」的结果
(⚠️ 上面把 indent=2 的多行输出压排了以省篇幅 —— 实跑时每个 key 独占一行,值一致)
⭐ 四条信息,四个来源,一一对应:
| schema 字段 | 从哪来 | 你在代码里怎么控制它 |
|---|---|---|
name |
fn.__name__ |
函数名。⚠️ 装饰器里要 functools.wraps,否则这里拿到的是包装函数的名字 |
description |
fn.__doc__ |
docstring —— 模型靠它决定要不要调这个工具 |
properties 的类型 |
get_type_hints(fn) |
类型标注 |
required |
signature 里有没有默认值 |
给了默认值就是可选 |
⭐ 这解释了一个常被问的问题:为什么写 Agent 工具时「docstring 要认真写」? 因为它不是注释,是发给模型的接口文档,而且是唯一的那一份。 同理,参数给不给默认值不只是编码习惯,它直接决定了 schema 里这个字段必填还是选填。
⚠️ 装饰器本身怎么写、functools.wraps 不写会丢掉什么,在
03 章;这一章只管「标注怎么被读出来」这半边。
🧩 三、⚠️ 一个会把上面那段打回原形的坑:字符串化的标注
在文件顶部加一行 from __future__ import annotations,所有标注会变成字符串:
# ann_string.py —— 加了这一行之后,标注全变成字符串
from __future__ import annotations
import inspect
import typing
def f(x: int, y: list[str]) -> bool:
return bool(x) and bool(y)
print("__annotations__ :", f.__annotations__)
print("signature :", repr(inspect.signature(f).parameters["x"].annotation))
print("get_type_hints :", typing.get_type_hints(f))
实跑输出:
__annotations__ : {'x': 'int', 'y': 'list[str]', 'return': 'bool'}
signature : 'int'
get_type_hints : {'x': <class 'int'>, 'y': list[str], 'return': <class 'bool'>}
⚠️ 前两行拿到的是字符串 'int',不是类型 int。
如果你的 @tool 里写的是 PY2JSON.get(sig.parameters[name].annotation),
在这种文件里会全部落到 else 分支,每个参数的类型都变成 "string" ——
schema 生成了,也不报错,只是全错,而模型会按错的 schema 给你传参。
⭐ 所以规则很简单:要类型就用 typing.get_type_hints,要参数结构就用 inspect.signature。
只有 get_type_hints 会把字符串解析回真正的类型对象。
🗓️ 这块是会变的:from __future__ import annotations(PEP 563)本来是为「标注太贵、想延迟求值」设计的,
后来 PEP 649 换了另一条路(惰性求值但拿到的是真对象)。
⭐ 不管未来默认成哪种,get_type_hints 都是那个正确答案 —— 这也是为什么建议只记这一条。
📋 四、标注的词汇表:够用的就这几个
| 写法 | 意思 | 什么时候用 |
|---|---|---|
int str bool float |
就是那个类型 | 默认 |
list[str] dict[str, int] |
带元素类型的容器 | ⭐ 3.9+ 直接用内置名,不用 typing.List |
str \| None |
要么 str 要么 None | 等价于老写法 Optional[str] |
int \| str |
二选一 | 等价于 Union[int, str] |
Literal["fast", "quality"] |
只能是这几个字面值之一 | ⭐ 比 str 有用得多:模式开关、枚举参数 |
Callable[[str], int] |
收一个 str 返回 int 的函数 | 回调、钩子 |
Any |
放弃标注 | ⚠️ 它会关掉这一处的检查,不是「什么都行」的安全写法 |
⭐ Literal 值得单独说:一个 mode: str 只告诉你「是个字符串」,
而 mode: Literal["fast", "quality"] 同时告诉了静态检查器、schema 生成器和读代码的人
合法取值只有两个。在 Agent 工具里,它会直接变成 JSON schema 的 enum,模型就不会瞎编第三个值。
🚦 Protocol:鸭子类型的显式版,但别拿它当运行时校验
# protocol_demo.py —— Protocol 是结构化子类型:长得像就算
from typing import Protocol, runtime_checkable
@runtime_checkable
class Encoder(Protocol):
def encode(self, text: str) -> list[float]: ...
class MyEncoder: # ⭐ 没有继承 Encoder
def encode(self, text):
return [0.1, 0.2]
class NotAnEncoder:
def fit(self, x):
return x
class Liar:
encode = "我不是方法,我是个字符串" # ⚠️ 只是有这个名字
print(isinstance(MyEncoder(), Encoder))
print(isinstance(NotAnEncoder(), Encoder))
print(isinstance(Liar(), Encoder))
实跑输出:
True
False
True
⭐ 第一个 True 是 Protocol 的价值:MyEncoder 没有继承任何东西,
只因为有个 encode 方法就算数 —— 这正是 Python 一直在用的鸭子类型,只是现在写下来了。
⚠️ 第三个 True 是它的边界:Liar 的 encode 是个字符串,
runtime_checkable 的 isinstance 只查属性名在不在,不查是不是可调用、更不查签名。
所以:Protocol 用来给静态检查器和读者表达意图,别拿它当运行期的门卫。
🛑 读到这里可以停 —— 已经读了约 28 分钟。 最后一段还有(约 24 分钟):标注 ≠ 校验:pydantic 和 mypy 解决的不是同一个问题 · 该标哪、不该标哪 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
🧯 五、标注 ≠ 校验:pydantic 和 mypy 解决的不是同一个问题
这是全章最容易混的一处。两样东西都跟类型有关,但跑的时机、失败的形式、能挡的东西全都不同:
| mypy / pyright(静态检查) | pydantic(运行时校验) | |
|---|---|---|
| 什么时候跑 | 你的代码根本没运行,它读源码 | 每次构造对象时都跑 |
| 输入从哪来 | 你自己写的代码 | ⭐ 外面来的数据:HTTP 请求体、配置文件、模型返回的 JSON |
| 失败长什么样 | 命令行一条 error,CI 里挂掉 | 抛 ValidationError,你要接住它 |
| 挡得住 | 「我这里把 None 传给了要 str 的函数」 |
「用户传了 max_tokens: "很多"」 |
| 挡不住 | 运行时才知道的东西(外部数据) | 你自己代码里的类型错误(它只管进出口) |
# pyd_vs_ann.py —— 同样的标注,一个校验一个不校验
from pydantic import BaseModel, ValidationError
class ChatReq(BaseModel):
prompt: str
max_tokens: int = 256
print(ChatReq(prompt="hi", max_tokens="512")) # ⚠️ 字符串被【转换】成 int
try:
ChatReq(prompt="hi", max_tokens="很多")
except ValidationError as e:
err = e.errors()[0]
print(err["type"], "|", err["msg"])
实跑输出:
算一算
prompt='hi' max_tokens=512
int_parsing | Input should be a valid integer, unable to parse string as an integer
⭐ 第一行值得盯一会儿:传进去的是字符串 "512",出来的是整数 512 ——
pydantic 默认会做类型转换,不是「不合格就拒绝」。这对 Web 请求是好事(查询参数天生是字符串),
但⚠️ 如果你指望它当断言用,它会安静地把你的数据改掉。想要严格拒绝要显式开严格模式。
⭐ 该怎么选:边界上用 pydantic,边界内用 mypy。 数据从外面进来的那一层(HTTP 请求、读配置、解析模型返回的 JSON)用运行时校验, 因为那些数据你控制不了;进来之后的内部函数之间用静态检查就够了, 给每个内部函数都套一层运行时校验只会让代码变慢变吵。
⚠️ 站里推荐过静态检查这件事:《智能体工程教程》第 5 章两次写着
「如果你的项目没有测试,先加类型检查(mypy / tsc --noEmit),几秒跑完,能挡掉一大批错误」。
那条建议成立,但它没说标什么、标到什么程度 —— 下一节就是那半张答案。
📋 六、该标哪、不该标哪
⭐ 判据只有一条:这个函数的类型信息,会不会被「你以外的人或程序」读? 会读就标。
| 位置 | 标不标 | 为什么 |
|---|---|---|
| 包/模块的对外函数 | ⭐ 一定标 | 别人(和半年后的你)唯一能快速看懂参数形状的地方 |
| Agent 工具函数 | ⭐ 一定标 | 标注直接变成 schema,不标模型就不知道该传什么 |
| Web 请求/响应模型 | ⭐ 一定标 | pydantic 靠它做校验和文档 |
| 数据类 / 配置类 | ⭐ 标 | 字段一多,不标就得靠猜 |
| 内部辅助函数 | 看情况 | 参数超过 3 个、或有 None 语义时标;否则可省 |
| 一次性实验脚本 / notebook 单元格 | ❌ 别标 | 收益接近 0,纯粹是打字负担 |
| 局部变量 | ❌ 一般别标 | 只在类型推不出来时才标(比如空容器 xs: list[int] = []) |
⚠️ 不要为了「覆盖率」去标。标注最坏的形态是过时的标注 —— 它比没有标注更糟,因为读的人会信它。⭐ 标了就要让 CI 跑检查, 否则它就是一句没人验证的注释;这也是为什么「先加类型检查再加标注」这个顺序是对的。
💡 一个低成本的起手式:不要全项目开检查,先只开最严格但范围最小的一块 —— 选一个模块,标它的对外函数,把检查器跑在这一个模块上。挡住的第一个真 bug 会告诉你要不要继续铺。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 03 · 装饰器与上下文管理器 | 本章讲「标注怎么被读出来」,那一章讲「装饰器怎么把读出来的东西挂回函数上」。⚠️ 尤其是 functools.wraps —— 不写它,本章 schema 里的 name 和 description 会全错 |
| 07 · 异常、traceback 和调试 | 标注不检查 → 错误类型会一路流到很远的地方才炸。那一章讲怎么从那种 traceback 倒推回来 |
| 智能体工程教程 18 · 挑战项目A-手搓Agent框架 | ⭐ 那一题的 T1 就是「读类型注解和 docstring 自动生成 schema」。本章第二节的 30 行就是它的最小可跑版,去那边把它做完整(Literal → enum、嵌套对象、错误提示) |
| 智能体工程教程 05 · ClaudeCode实操-指令该放哪 | 它两次推荐「没有测试就先加类型检查」。本章第五、六节补上它没说的那半张:标什么、标到什么程度、mypy 和 pydantic 的分工 |
| AI全栈 03 · 后端骨架 | 那一章的依赖注入写成 Annotated[dict, Depends(current_user)] —— 那正是「标注被程序读出来」的另一个用法:框架从标注里读出「这个参数该由谁提供」 |
| AI全栈 15b · 怎么测不确定的系统 | 类型检查挡不住的那一大类(值对不对、行为对不对)归测试。两者是互补的,不是替代 |
✅ 检查点
def add(a: int, b: int) -> int传两个字符串进去会发生什么?为什么?- 一个
@tool装饰器生成 schema 时,description、required、参数类型分别是从函数的哪三处读出来的? - 为什么说「参数给不给默认值」不只是编码习惯?
- 文件顶部写了
from __future__ import annotations之后,inspect.signature(f).parameters["x"].annotation拿到的是什么?该改用什么? runtime_checkable的Protocol配isinstance检查的是什么、不检查什么?举一个会误判的例子。- pydantic 和 mypy 各自在什么时候跑?各自挡得住哪一类错误?
ChatReq(prompt="hi", max_tokens="512")里的max_tokens最后是什么类型、什么值?这件事什么时候是好事、什么时候是坑?- 哪三类代码「一定要标」、哪两类「别标」?判据是什么一句话?
👀 答案
- 打印出
你好世界,不报错。标注只是被存进add.__annotations__字典的一个表达式结果,解释器不看它;+对两个 str 就是拼接,函数返回了 str 而标注写着-> int。 description←fn.__doc__(docstring 第一行);required←inspect.signature里有没有默认值(没有默认值 = 必填);参数类型 ←typing.get_type_hints(fn)。- 因为它直接决定 schema 里这个字段必填还是选填。给了默认值 = 可选,模型可以不传;没给 = 进
required。 - 拿到的是字符串
'int',不是类型对象int。改用typing.get_type_hints(f),它会把字符串解析回真类型({'x': <class 'int'>, 'y': list[str], ...})。规则:要类型用get_type_hints,要参数结构用signature。 - 只检查属性名在不在,不检查是不是可调用、更不检查签名。误判例子:
class Liar: encode = "我不是方法"——isinstance(Liar(), Encoder)返回 True。 - mypy 在代码根本没运行时读源码跑,挡「我这里把
None传给了要str的函数」;pydantic 在每次构造对象时跑,挡外部进来的脏数据(请求体、配置、模型返回的 JSON)。前者管代码内部,后者管边界。 - 是整数
512—— pydantic 默认做类型转换。对 Web 请求是好事(查询参数天生是字符串);⚠️ 但如果你指望它当断言用,它会安静地把数据改掉,那时就是坑,要开严格模式。 - 一定标:包/模块的对外函数、Agent 工具函数、Web 请求响应模型(数据类也算);别标:一次性实验脚本 / notebook、一般的局部变量。判据一句话:这个类型信息会不会被「你以外的人或程序」读。
🛑 可以停在这里
⚡ 走神救援
⭐⭐ 类型标注在运行时一个字都不检查。 给两个
int参数传字符串进去,实跑是正常拼接、不报错、不警告。标注只是被求值后塞进一个普通字典,你甚至可以在运行时改掉它。💀 它最贵的形态是静默类型污染:一个本该是整数的参数收到了字符串,乘以 2 得到的是重复两遍的字符串——一个合法但错误的值,一路流到很远才炸。
⭐ 那它有什么用?全部价值是「能被程序读出来」。 自动生成工具 schema 只要两个函数就够:一个拿参数名和默认值、一个拿解析好的类型。⭐ 这解释了两件事:docstring 不是注释,是发给模型的接口文档;给不给默认值直接决定字段必填还是选填。
⚠️ 一个会把它打回原形的坑:文件顶部一旦开了「标注延迟求值」,标注全变成字符串,于是 schema 里所有类型静默落成字符串类型——生成了、不报错、全错。⭐ 规则:要类型用
get_type_hints,要参数结构用signature。词汇表里最被低估的是
Literal:⭐ 它会直接变成 schema 的枚举,模型就编不出第三个值。Protocol是鸭子类型的显式版,⚠️ 但它的运行期检查只查属性名在不在——一个把方法写成字符串的冒牌货照样通过,所以它是给静态检查器和读者看的,不是运行期门卫。⭐⭐ 标注不等于校验:静态检查器在代码没运行时读源码、管你自己写的代码;数据校验库在每次构造对象时跑、管外面进来的数据。⚠️ 而且后者默认做转换而不是拒绝——对 Web 请求是好事,当断言用就是坑。⭐ 分工:边界上用校验库,边界内用静态检查。
⭐ 判据一句话:类型信息会不会被你以外的人或程序读。 ⚠️ 最坏的形态是过时的标注,它比没有更糟,因为读的人会信它——所以标了就要让 CI 跑检查。
下一节 👉 07-异常traceback和调试.md