🏠 总目录📚 本教程 06 · 类型标注是给工具看的 ← →
📑 本页目录(点开跳转)

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 · 怎么测不确定的系统 类型检查挡不住的那一大类(值对不对、行为对不对)归测试。两者是互补的,不是替代

✅ 检查点

  1. def add(a: int, b: int) -> int 传两个字符串进去会发生什么?为什么?
  2. 一个 @tool 装饰器生成 schema 时,description、required、参数类型分别是从函数的哪三处读出来的?
  3. 为什么说「参数给不给默认值」不只是编码习惯?
  4. 文件顶部写了 from __future__ import annotations 之后,inspect.signature(f).parameters["x"].annotation 拿到的是什么?该改用什么?
  5. runtime_checkable 的 Protocol 配 isinstance 检查的是什么、不检查什么?举一个会误判的例子。
  6. pydantic 和 mypy 各自在什么时候跑?各自挡得住哪一类错误?
  7. ChatReq(prompt="hi", max_tokens="512") 里的 max_tokens 最后是什么类型、什么值?这件事什么时候是好事、什么时候是坑?
  8. 哪三类代码「一定要标」、哪两类「别标」?判据是什么一句话?
👀 答案
  1. 打印出 你好世界,不报错。标注只是被存进 add.__annotations__ 字典的一个表达式结果,解释器不看它;+ 对两个 str 就是拼接,函数返回了 str 而标注写着 -> int。
  2. description ← fn.__doc__(docstring 第一行);required ← inspect.signature 里有没有默认值(没有默认值 = 必填);参数类型 ← typing.get_type_hints(fn)。
  3. 因为它直接决定 schema 里这个字段必填还是选填。给了默认值 = 可选,模型可以不传;没给 = 进 required。
  4. 拿到的是字符串 'int',不是类型对象 int。改用 typing.get_type_hints(f),它会把字符串解析回真类型({'x': <class 'int'>, 'y': list[str], ...})。规则:要类型用 get_type_hints,要参数结构用 signature。
  5. 只检查属性名在不在,不检查是不是可调用、更不检查签名。误判例子:class Liar: encode = "我不是方法" —— isinstance(Liar(), Encoder) 返回 True。
  6. mypy 在代码根本没运行时读源码跑,挡「我这里把 None 传给了要 str 的函数」;pydantic 在每次构造对象时跑,挡外部进来的脏数据(请求体、配置、模型返回的 JSON)。前者管代码内部,后者管边界。
  7. 是整数 512 —— pydantic 默认做类型转换。对 Web 请求是好事(查询参数天生是字符串);⚠️ 但如果你指望它当断言用,它会安静地把数据改掉,那时就是坑,要开严格模式。
  8. 一定标:包/模块的对外函数、Agent 工具函数、Web 请求响应模型(数据类也算);别标:一次性实验脚本 / notebook、一般的局部变量。判据一句话:这个类型信息会不会被「你以外的人或程序」读。

🛑 可以停在这里

⚡ 走神救援

⭐⭐ 类型标注在运行时一个字都不检查。 给两个 int 参数传字符串进去,实跑是正常拼接、不报错、不警告。标注只是被求值后塞进一个普通字典,你甚至可以在运行时改掉它。

💀 它最贵的形态是静默类型污染:一个本该是整数的参数收到了字符串,乘以 2 得到的是重复两遍的字符串——一个合法但错误的值,一路流到很远才炸。

⭐ 那它有什么用?全部价值是「能被程序读出来」。 自动生成工具 schema 只要两个函数就够:一个拿参数名和默认值、一个拿解析好的类型。⭐ 这解释了两件事:docstring 不是注释,是发给模型的接口文档;给不给默认值直接决定字段必填还是选填。

⚠️ 一个会把它打回原形的坑:文件顶部一旦开了「标注延迟求值」,标注全变成字符串,于是 schema 里所有类型静默落成字符串类型——生成了、不报错、全错。⭐ 规则:要类型用 get_type_hints,要参数结构用 signature。

词汇表里最被低估的是 Literal:⭐ 它会直接变成 schema 的枚举,模型就编不出第三个值。 Protocol 是鸭子类型的显式版,⚠️ 但它的运行期检查只查属性名在不在——一个把方法写成字符串的冒牌货照样通过,所以它是给静态检查器和读者看的,不是运行期门卫。

⭐⭐ 标注不等于校验:静态检查器在代码没运行时读源码、管你自己写的代码;数据校验库在每次构造对象时跑、管外面进来的数据。⚠️ 而且后者默认做转换而不是拒绝——对 Web 请求是好事,当断言用就是坑。⭐ 分工:边界上用校验库,边界内用静态检查。

⭐ 判据一句话:类型信息会不会被你以外的人或程序读。 ⚠️ 最坏的形态是过时的标注,它比没有更糟,因为读的人会信它——所以标了就要让 CI 跑检查。

下一节 👉 07-异常traceback和调试.md

打卡记录保存在你的浏览器里,首页能看到总进度