跳到主要内容
QZ Site
首页写作项目关于

© 2026 QZ Site. All rights reserved.

豫ICP备2026034998号-1|琼公网安备46020002000486号

← 返回写作

MiniClaude:个人 Agent 早期版本学习笔记 · 2

02-类型系统与注解盲区梳理

MiniClaude·2026年5月26日·首读:从概述按主题顺读;复习:按速查表或目录定位问题。
观点

读 Agent 代码时,先问一句:谁在什么时候检查什么? 普通注解、Pydantic 模型、Protocol 和 ABC 都能表达约束,检查时机和覆盖范围却不一样。把这几层分开,才不会把“写了类型”误读成“数据已经安全”。

本文以 Python 3.12、Pydantic v2 为基线,示例是独立教学代码,不代表某个 MiniClaude 提交的实现。Python 3.14 的注解变化单独说明。基础语法、数据类默认值见 01-Python 语法盲区梳理;协程怎样运行、任务怎样收尾见 03-asyncio 与并发模型盲区梳理。本篇依次看检查、导入、模型、接口与事件解码。

1 类型注解不等于运行时验证

下面两个入口都写了 int,但真正执行的工作不同:

python
from pydantic import BaseModel, ValidationError

def echo(value: int) -> int:
    return value

class User(BaseModel):
    age: int

print(type(echo("18")).__name__)  # str,故意违反注解
user = User.model_validate({"age": "18"})
print(user.age, type(user.age).__name__)  # 18 int
for data, strict in [({"age": "abc"}, False), ({"age": "18"}, True)]:
    try:
        User.model_validate(data, strict=strict)
    except ValidationError as exc:
        print(exc.errors()[0]["type"])  # int_parsing;int_type

普通函数的参数和返回值注解不会自动拦截这次调用。若 add(a: int, b: int) 的函数体执行 a + b,传入 "1", 2 会报错,原因是字符串和整数不能这样相加,而不是注解先检查了实参。

mypy 检查源码,Pydantic 验证送进来的数据。 mypy 根据注解、赋值和调用关系发现不一致,不需要执行上述函数。安装后可用 python -m mypy checks.py 检查保存的代码;编辑器、命令行、CI 都能接入,检查失败是否阻止合并由流程决定。覆盖程度还取决于配置和类型信息。注解写成字符串也不妨碍静态分析。Python 类型注解说明

Pydantic 在构造或显式验证时处理真实输入,默认允许某些转换;本例的 "18" 可变成整数,"abc" 不行,strict=True 又会拒绝字符串数字。严格规则依字段和 Python/JSON 输入来源而异,不能把整数例子推广到所有类型。两种检查互补:静态通过不保证外部输入合法,模型通过也不保证后续业务正确。Pydantic 严格模式

cast 和 TypeGuard 各做了什么

python
from typing import TypeGuard, cast

raw: object = ["goal", 7]
required = cast(list[str], raw)
print(required is raw)  # True,没有转换或检查元素

def is_str_list(value: object) -> TypeGuard[list[str]]:
    return isinstance(value, list) and all(isinstance(x, str) for x in value)

print(is_str_list(raw))  # False
candidate: object = ["goal", "name"]
if is_str_list(candidate):
    print(", ".join(candidate))  # goal, name;检查器在此收窄类型

cast(T, x) 适合“已有证据确认类型,但检查器推不出来”的地方,不适合拿来验证外部输入。TypeGuard 的保护来自函数体里的真实检查;注解只告诉检查器如何理解 True,无条件返回真仍会误导它。

还要分清:dict(data) 构造字典,由已有字典构造时只是浅拷贝;dict[str, object] 是类型表达式,本身不验证数据。原笔记中的 tool.params_model.model_validate(dict(tool_call.input)),就是先构造字典,再调用模型验证。是否把 ValidationError 转成 schema_error,要继续查调用方的捕获逻辑,不能从这一行或 cast 推定整个入口已安全。

2 导入错误先看名字何时可用

首次正常导入模块会执行顶层代码。模块尚未执行完,就可能因另一个 import 暂停;此时它已经进入缓存,但部分名字还没定义。循环依赖不一定报错,提前取出不存在的名字才是关键。

两个独立模块怎样互相引用结果
a 顶层先 from b import B;b 又立即 from a import A;a 尚未定义 AImportError,提示 partially initialized module
a、b 先 import 对方,仅在两个模块加载完成后的函数调用中访问对方成员可以成功;若函数之后无终止条件地互调,则是运行时无限递归

Java 的 import 主要参与名称解析,不能直接套用 Python 的顶层执行顺序;Java 也有类初始化问题,不宜概括成“Java 没有循环问题”。

前向引用与 future 的保护范围

以下是一个完整的独立文件,不能把 future 语句追加到已有普通代码后面:

python
from __future__ import annotations
from typing import get_type_hints

def make() -> B:
    return B()

class B:
    pass

print(make.__annotations__["return"])          # B,保存的是字符串
print(get_type_hints(make)["return"].__name__)  # B,解析得到类
print(type(make()).__name__)                   # B,这时才构造对象

在 Python 3.12 中,删掉第一行,定义 make 时就会因注解里的 B 尚未定义而报 NameError,不必等调用。也可以只把返回注解写成 "B"。future 启用的是 PEP 563 的字符串化行为;前面允许有模块文档字符串、注释、空行及其他 future 语句,但不能先执行普通语句。

return B() 仍在调用时执行,那时 B 已定义就可以正常工作。future 不会推迟普通 import、基类表达式、顶层构造和真实调用;前面失败的 from a import A 加上它仍然失败。不能把函数体中的正常引用一律标成错误。

TYPE_CHECKING 减少类型依赖,也可能留下解析缺口

这是类型导入片段,静态检查的前提是项目里确有 provider.py 和 Provider:

python
from __future__ import annotations
from typing import TYPE_CHECKING, get_type_hints
if TYPE_CHECKING:
    from provider import Provider

def use(p: Provider) -> Provider:
    return p

检查器把 TYPE_CHECKING 视为真,运行时它为假,因此不会为了注解导入 provider。可是直接调用 get_type_hints(use) 会因当前命名空间没有 Provider 而失败。等真实类可用后,可以提供包含它的 globalns,例如 get_type_hints(use, globalns={**globals(), "Provider": provider.Provider});这要求已在合适时机 import provider。

“能导入”不等于“框架能解析全部注解”。Pydantic 等库也需要正确的名称和解析时机,不能把运行时需要的导入全部藏进 TYPE_CHECKING。共享类型可下移到第三个底层模块;真实依赖可在合适时机导入;顶层互相构造对象则应调整初始化职责。拆一个公共模块不必直接拔高成“依赖倒置”。

3.14 的边界: PEP 649/749 让注解默认延迟求值,与 3.12 的字符串化机制不同;过早索取实际值仍可能遇到未定义名字。from __future__ import annotations 在 3.14 中仍保留字符串化行为,没有被删除。升级时还要检查读取注解的库是否兼容。Python 3.14 注解变化

3 模型负责怎样接收和交出数据

标准库 @dataclass 根据字段生成构造、显示、比较等样板,默认可变,不自动验证字段类型;它也能用 asdict() 处理嵌套数据类,或在 __post_init__ 中自行检查。field(default_factory=list) 为每个需要默认值的实例新建列表,这部分在 01 展开。

Pydantic 的 BaseModel 把验证和导出规则放在模型旁边。它不是 Python 的 object,也不是负责表映射、查询与持久化的 ORM Model。内部可信数据可用轻量数据类,外部输入或固定协议通常适合验证模型,但这不是硬分工:配置虽然在本进程消费,环境变量、用户文件和命令行参数仍可能不可信;两类模型完全可以同时使用。dataclass 与 Java record、BaseModel 与 Bean Validation/Jackson 都只能局部类比,不能直接等同。

Literal 的允许值与默认值是两件事

str 允许字符串,Literal["2.0"] 进一步限定为这个具体值;Literal["fast", "slow"] 则列出两个允许值,常用于协议版本、状态和事件标签。Literal 不会像 Java enum 那样创建独立的枚举类型。普通函数注解不会因此自动检查;放进 Pydantic 字段才由模型执行验证。字符串 "2.0" 也不同于数值 2.0。

下面沿“构造结果 → 包装回复 → 导出 → 读回”走一遍:

python
from typing import Literal
from pydantic import BaseModel

class PongResult(BaseModel):
    server_version: str
    uptime_ms: int
    received_at: str

class JsonRpcSuccess(BaseModel):
    jsonrpc: Literal["2.0"] = "2.0"
    id: str
    result: PongResult

pong = PongResult(server_version="0.1.0", uptime_ms=42, received_at="2026-07-31")
response = JsonRpcSuccess(id="1", result=pong)
payload = response.model_dump()
wire = response.model_dump_json()
print(type(payload["result"]).__name__)  # dict
print(type(JsonRpcSuccess.model_validate(payload).result).__name__)  # PongResult
print(type(JsonRpcSuccess.model_validate_json(wire).result).__name__)  # PongResult

= "2.0" 让构造时可以省略 jsonrpc;Literal["2.0"] 则拒绝显式传入的 "1.0" 或数值 2.0。仅写 jsonrpc: str = "2.0" 不能限制其他字符串。

方法方向与结果
model_dump()模型 → Python 字典,嵌套模型递归处理
model_dump_json()模型 → JSON 文本,无须为这个结构手写 to_json()
model_validate(data)Python 数据 → 验证后的模型
model_validate_json(text)JSON 文本 → 解析、验证后的模型

model_dump() 的 Python 模式可能保留日期等对象;要 JSON 兼容数据用 model_dump(mode="json"),要文本才用 model_dump_json()。特殊对象仍可能需要序列化支持,不能承诺任意对象都零配置。Pydantic 序列化

能导出,不代表能恢复原类型。 若上例把 result: PongResult 改成 result: Any(先从 typing 导入 Any),嵌套模型仍能导出;从这份 JSON 读回时却只会得到 dict,因为 Any 没告诉库要恢复成 PongResult。多种回复应按需要设计联合类型或其他约束,使用 Any 就要清楚放宽了哪层检查。一次验证成功也不保证之后任意赋值自动重验,赋值验证需要另行配置。

4 Protocol 与 ABC 约束的是对象能力

模型主要规定“数据长什么样”,接口还规定“对象能做什么”。先看调用方真正需要的能力:

python
import asyncio
from typing import Protocol

class LLMProvider(Protocol):
    async def chat(self, messages: list[str]) -> str: ...

class DemoProvider:
    async def chat(self, messages: list[str]) -> str:
        return "hi"

async def use(provider: LLMProvider) -> str:
    return await provider.chat(["hello"])

print(asyncio.run(use(DemoProvider())))  # hi

DemoProvider 没有继承 LLMProvider,仍可按结构满足接口,适合第三方适配、替换实现和测试替身。有同名方法不够:参数、返回值、所需属性及同步/异步调用形式都要兼容。 签名不必逐字相同,但必须支持接口承诺的用法。普通 Protocol 不在每次调用前验证;@runtime_checkable 支持的部分 isinstance 检查,也不验证完整签名和业务行为。Protocol 规则

ABC 是标准库 abc 模块的抽象基类机制,常用显式继承组织共享逻辑,并阻止未实现抽象成员的类实例化:

python
from abc import ABC, abstractmethod

class BaseTool(ABC):
    name: str
    description: str
    input_schema: dict[str, object]
    @abstractmethod
    async def invoke(self, params: dict[str, object]) -> str: ...

class BareTool(BaseTool):
    async def invoke(self, params: dict[str, object]) -> str:
        return "done"

print(hasattr(BareTool(), "name"))  # False,但可以实例化
# BaseTool() 或未实现 invoke 的子类实例化,会抛 TypeError

name: str 没有赋值,也不是抽象属性。需要强制子类提供属性,可用 @property 配合 @abstractmethod;名称格式、schema 内容、覆写签名和返回值仍需静态检查、运行时检查或测试。ABC 还支持虚拟子类:注册不注入方法,也不会按普通继承路径强制补齐抽象成员,因此不能说所有被视为某 ABC 的类都必须显式继承。ABC 官方说明

Protocol 可借 Java interface 理解,但通常不要求 implements;ABC 可借 abstract class 理解,也不能照搬所有继承规则。两者按能力需求选择,都不能替代输入模型验证。读具体工具框架时,还要查注册流程是否另外检查元数据。

5 事件 JSON 怎样选中正确模型

事件有共同入口,却有不同字段。判别联合把“根据标签选模型,再验证字段”集中在声明中:

python
from typing import Annotated, Literal
from pydantic import BaseModel, Field, TypeAdapter

class RunStarted(BaseModel):
    type: Literal["run.started"] = "run.started"
    run_id: str

class StepStarted(BaseModel):
    type: Literal["step.started"] = "step.started"
    step: int

Event = Annotated[RunStarted | StepStarted, Field(discriminator="type")]
adapter = TypeAdapter(Event)
event = adapter.validate_json('{"type":"run.started","run_id":"r1"}')
print(type(event).__name__)  # RunStarted
print(event.model_dump_json())  # {"type":"run.started","run_id":"r1"}

Literal 定标签;联合列候选,A | B 从 3.10 起可用,也可写 Union[A, B];Annotated 附加元数据,Field 指定给 Pydantic 读的判别字段;TypeAdapter 让这个类型表达式获得运行时验证等能力,无须再包 BaseModel。Event 是类型别名,不是一条事件实例。

把不同输入送进上例 adapter,失败位置不同,都会通过 ValidationError 报告:

输入问题失败位置错误类型与首个位置
{"type":"other","run_id":"r1"}不认识标签union_tag_invalid,()
{"run_id":"r1"}无标签,无法选择union_tag_not_found,()
{"type":"run.started"}选中了模型,缺字段missing,('run.started', 'run_id')
{"type":"step.started","step":"many"}选中了模型,字段无效int_parsing,('step.started', 'step')
注意

标签有默认值,不等于判别输入可以省标签。直接构造 RunStarted(run_id="r1") 已经选好了类;未知输入则必须先告诉 adapter 选谁。

只有 Literal 字段也不等于显式配置 discriminator。普通 TypeAdapter(RunStarted | StepStarted) 能验证联合,但其 schema 没有这里的 discriminator 声明;adapter.json_schema()["discriminator"]["propertyName"] 才得到 type。Pydantic v2 另有支持字符串或可调用逻辑的 Discriminator;v1.10 已有显式判别联合,不是“v1 自动推断,v2 才显式声明”。v2 联合类型、v1 判别联合

与手写分支相比,省下什么

手写解析同样可以正确:json.loads 后先确认是字典,再按 type 分支调用相应的 model_validate,对未知/缺失标签报错。它可以集中成一个函数,也可以用统一映射分派,不必每处重写 if-else。

判别联合的价值是集中候选模型、标签关系、验证与 schema,错误更聚焦,减少无关分支尝试。JSON 解析、容器遍历、字段与嵌套模型验证仍有成本,不能说整个反序列化是 O(1),也不能仅凭写法断定一定比手写映射快。新增事件还要定义模型、稳定且不冲突的标签、加入联合,并检查消费者、日志/界面展示和旧客户端兼容;“只改一处、自动满足开闭原则”都过头了。

进程内可直接传模型对象;socket、历史日志通常需要重新解码。但判别联合也能用 validate_python 处理 Python 数据,不限于 IPC。熟悉 Jackson 的话,可类比 @JsonTypeInfo 与子类型映射的选型思路,不等于两个库行为完全相同。

手写分支展开步骤,带有命令式特点;模型声明表达允许什么结构,把执行交给库,带有声明式特点。两者可以共存。SQL 是更典型的声明式例子,正则、HTML、Spring DI 的配置也可帮助理解“描述规则或结果”;列表推导式虽比 for/append 集中,仍包含遍历与条件。map/filter/reduce、EventBus 订阅配置也不能替整个程序贴标签,库内部仍有判断和循环。学 Agent 时,先读清发布订阅、依赖注入、接口和一次 ReAct 运行循环,不必另背“范式在第几层”的金字塔。

读回源码时,沿一条数据查

输入从哪里来,谁验证,用什么对象传递,在哪里导出,由谁读回。类型不一致看静态检查;导入失败看顶层执行和求值时机;字段错误看验证入口;能力不兼容看签名;事件失败先查标签再查字段。这些检查不是互斥的,也不会因 Any、绕过入口或缺少配置而自动补齐。

原学习记录中的 core/tools/invocation.py、bus/events.py、S0/S1/S2 是回查线索。具体事件数量、某阶段是否调用解码、源码是否使用 cast,都应核对对应提交,不能从本篇教学例推定。

系列目录 · MiniClaude:个人 Agent 早期版本学习笔记

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

相关文章

同一系列

MiniClaude:个人 Agent 早期版本学习笔记 · MiniClaude

06-工程实践与工具链盲区梳理

围绕个人 Agent 早期版本学习中的工程问题,整理 Git、命令行、配置管理、浅拷贝与可变性、防御性编程等要点,配合速查与自检使用。

2026年8月1日·首读:从概述按主题顺读;复习:按速查表或目录定位问题。

同一系列

MiniClaude:个人 Agent 早期版本学习笔记 · MiniClaude

05-IPC 与网络通信盲区梳理

从进程内事件走向跨进程通信,整理 JSON-RPC、TCP、请求响应配对、事件推送与客户端协程分工。项目阶段和实现细节需结合对应源码版本阅读。

2026年8月1日·首读:从概述按主题顺读;复习:按速查表或目录定位问题。

同一系列

MiniClaude:个人 Agent 早期版本学习笔记 · MiniClaude

04-Agent 架构与设计模式盲区梳理

按 ReAct 循环、工具调用、错误处理、事件系统、消息流与任务系统整理 Agent 架构盲区。可先选一个数据流读,再回到速查表和自检题。

2026年8月1日·首读:从概述按主题顺读;复习:按速查表或目录定位问题。

目录

  • 1 类型注解不等于运行时验证
  • cast 和 TypeGuard 各做了什么
  • 2 导入错误先看名字何时可用
  • 前向引用与 future 的保护范围
  • TYPE_CHECKING 减少类型依赖,也可能留下解析缺口
  • 3 模型负责怎样接收和交出数据
  • Literal 的允许值与默认值是两件事
  • 4 Protocol 与 ABC 约束的是对象能力
  • 5 事件 JSON 怎样选中正确模型
  • 与手写分支相比,省下什么
  • 读回源码时,沿一条数据查
6 篇文章
01-Python 语法盲区梳理
02-类型系统与注解盲区梳理
03-asyncio 与并发模型盲区梳理
04-Agent 架构与设计模式盲区梳理
05-IPC 与网络通信盲区梳理
06-工程实践与工具链盲区梳理
← 上一篇01-Python 语法盲区梳理
下一篇 →03-asyncio 与并发模型盲区梳理