一个修改商品价格的功能,接口检查价格是不是整数,应用服务检查一次,领域对象检查一次,保存时又检查一次。代码分成了四层,同一个问题也被问了四遍。 这些检查看起来都在提高可靠性,但如果删掉其中一条,我们能说清楚究竟失去了什么保护吗? 之前的《让领域模型和仓储模型各自负责》讨论了模型与持久化之间的边界。这篇继续讨论一个更具体的问题:边界已经划分之后,哪些检查应该保留,哪些可以交给前面的代码保证? 如果你已经接触过 Python 的 dataclass 和简单分层,但觉得业务代码越来越像校验框架,可以从下面这个改价功能开始。 一个价格,为什么检查四遍 假设商品后台收到这样的 JSON: { "sku": "P1001", "price_cents": 9900 } 价格使用整数分。对于这个例子,业务还约定:价格不能为负数,停售商品不能改价。 容易写出的代码是,在入口、服务、模型和 Repository 中都放上类似判断: if type(price_cents) is not int: raise ValueError("价格必须是整数分") 问题在于,这四次检查通常处理的是同一个已经解析完成的值。只要内部代码没有重新引入不可信数据,重复检查提供的保护就很有限。 真正需要区分的是两个问题: 问题 例子 负责的位置 输入是否符合接口约定? 缺少价格、价格是字符串 外部输入入口 操作是否符合业务规则? 负价格、对停售商品改价 领域模型 应用服务负责组织操作:加载商品、调用改价方法、保存商品。Repository 负责读写和映射。它们不需要把前两类判断各自重写一份。 先建立一个可以信任的入口 Python 的类型注解不会自动验证运行时数据。写下 price_cents: int,并不能阻止调用方传入字符串。 因此,“内部可以信任类型”需要前提:外部数据进入内部流程之前,确实经过了解析和检查。 下面是这个入口。示例采用严格协议,只接受两个字段;不自动把字符串价格转换成整数。 from dataclasses import dataclass, replace @dataclass(frozen=True) class ChangePriceRequest: sku: str price_cents: int def decode_change_price(data: object) -> ChangePriceRequest: if not isinstance(data, dict): raise ValueError("请求必须是 JSON 对象") if set(data) != {"sku", "price_cents"}: raise ValueError("请求必须且只能包含 sku 和 price_cents") sku = data["sku"] if not isinstance(sku, str) or not sku.strip(): raise ValueError("sku 必须是非空字符串") if type(data["price_cents"]) is not int: raise ValueError("价格必须是整数分") return ChangePriceRequest(sku.strip(), data["price_cents"]) 这里使用 type(value) is int,是因为 Python 的 bool 是 int 的子类。这个接口不接受 true 作为价格。 ...
让 Agent 少写防御性代码:每条校验都要有理由
一个策略选择工具,本来只需要让 Agent 查看模板、选择版本、取得写作规则。写着写着,代码却变成了另一副样子:遍历字段名、判断字典形状、检查数组元素、猜测字段类型,再把已经检查过的数据交给下一层继续检查。 每一条判断单独看都有解释,放在一起却让人看不清工具到底做什么。 这发生在一次写作策略工具教程的审查中。删除一个通用转换助手后,代码甚至更长了:检查没有减少,只是搬进了另一个函数。问题并不是少用了一个框架,也不是变量命名不够规范,而是还没决定哪些错误需要处理,就先把能想到的检查写了出来。 本文适合已经了解 Python dataclass、正在让 Agent 编写小型工具或分层应用的读者。以下例子从那次审查中缩小而来,用来说明设计取舍,不是完整策略工具的实现。 前两篇分别讨论模型与仓储的职责和入口检查与业务约束的分工。这篇往前追问一步:在决定校验放在哪里之前,我们为什么需要这条校验? 一个数组转换,怎么长成了通用校验器 假设模板包含 structure 字段。JSON 中它是数组,领域对象中希望用 tuple 表示: structure = tuple(data["structure"]) 这行代码完成转换,但不保证输入正确: assert tuple("post") == ("p", "o", "s", "t") 如果 Agent 把数组写成字符串,转换不会报错,错误会以另一种形式进入模型。因此,在接收 Agent 请求的地方,检查它是不是字符串数组是有具体理由的。 但这不意味着每个处理 structure 的方法,都应该重新执行下面三项检查: 它是不是数组? 数组里的元素是不是字符串? 数组是不是非空? 前两项属于输入协议;第三项取决于业务:这个模板是否允许没有正文结构?如果模型已经保证结构不能为空,入口无需重复保证一次。 同样的字段,也可能来自程序自己写出的文件。是否检查,要先确定文件的使用方式。 数据来源 能依赖的前提 处理方式 Agent 提交的编辑 JSON 尚未确认类型和字段 入口解析,拒绝不符合约定的结构 已解析的内部请求对象 调用者遵守既定契约 直接使用,不重复判断类型 同一程序写出、没有独立编辑入口的文件 写入与读取遵循同一格式 可以直接映射,加载失败时暴露错误 允许人工编辑或导入的文件 内容可能不符合格式 把文件读取当作输入边界,定义需要的检查 “放在仓库里”不等于“可信”。如果维护者经常手改 JSON,它就是一个真实的编辑入口。反过来,受控文件也不是永远不会损坏;选择直接映射,意味着不承诺为每一种损坏提供定制诊断,更不保证发现所有能被转换的错误类型。 一个能直接运行的例子 缩小需求:模板名称不能为空,结构至少有一项,每一项不能是空白文字。外部请求只接受 name 和 structure,不自动转换数字或字符串数组。 保存下面代码为 template_boundary.py,使用 Python 3.10 或更新版本运行: from dataclasses import dataclass @dataclass(frozen=True) class Template: name: str structure: tuple[str, ...] def __post_init__(self): if not self.name.strip(): raise ValueError("模板名称不能为空") if not self.structure or any(not step.strip() for step in self.structure): raise ValueError("模板必须包含非空的写作步骤") def decode_template(data: object) -> Template: if not isinstance(data, dict) or set(data) != {"name", "structure"}: raise ValueError("请求必须包含且只能包含 name 和 structure") if not isinstance(data["name"], str): raise ValueError("name 必须是字符串") steps = data["structure"] if not isinstance(steps, list) or not all(isinstance(step, str) for step in steps): raise ValueError("structure 必须是字符串数组") return Template(name=data["name"], structure=tuple(steps)) def writing_outline(template: Template) -> str: return "\n".join(f"{i}. {step}" for i, step in enumerate(template.structure, 1)) if __name__ == "__main__": template = decode_template({ "name": "问题与解释", "structure": ["提出具体问题", "解释原因与适用条件"], }) print(writing_outline(template)) cases = [ ({"name": "问题与解释", "structure": "post"}, "structure 必须是字符串数组"), ({"name": "问题与解释", "structure": [42]}, "structure 必须是字符串数组"), ({"name": "问题与解释", "structure": []}, "模板必须包含非空的写作步骤"), ({"name": "问题与解释", "structure": [" "]}, "模板必须包含非空的写作步骤"), ({"name": " ", "structure": ["解释"]}, "模板名称不能为空"), ] for payload, expected in cases: try: decode_template(payload) except ValueError as error: assert str(error) == expected else: raise AssertionError(f"错误输入被接受:{payload!r}") print("正常输入和 5 个失败场景均符合约定") 运行 python3 template_boundary.py,输出: ...
Pydantic 的 field_validator:字段什么时候校验,返回值去了哪里?
在请求模型里看到 @field_validator("score"),很容易以为它只是给方法加了一个标记:这个方法由谁调用?返回的值又去了哪里? 它的作用是告诉 Pydantic:校验 score 字段时,自动执行下面的方法,并把返回值作为这个字段的最终值。 本文适合已经会写 BaseModel、但还不熟悉自定义校验的读者,示例使用 Pydantic v2。 类型正确,还不够 假设接口接收一次考试成绩: { "score": 120 } int 可以说明成绩应当是整数,但 120 也是整数。对于满分 100 分的考试,还需要检查成绩是否在 0~100 之间。 这些规则可以放进字段校验器,在请求进入业务处理前统一完成。 一个可以直接运行的例子 把下面代码保存为 example.py,通过 uv run --with 'pydantic>=2,<3' python example.py 执行: from pydantic import BaseModel, ValidationError, field_validator class ExamResult(BaseModel): score: int @field_validator("score") @classmethod def validate_score(cls, value: int) -> int: if not 0 <= value <= 100: raise ValueError("成绩必须在 0~100 之间") return value result = ExamResult(score=85) assert result.score == 85 print(result.score) try: ExamResult(score=120) except ValidationError as exc: error = exc.errors()[0] assert error["loc"] == ("score",) print(error["msg"]) else: raise AssertionError("应该拒绝超过满分的成绩") 输出: ...
Skill、MCP 与 CLI:工具编排复杂了,就该上 MCP 吗?
Skill 与 MCP 解决不同层次的复用问题,CLI 也没有因此过时。本文用代码审查场景解释三者的边界,以及何时应该抽取脚本、何时值得引入 MCP。
如何实现一次 Agent 工具调用
这篇文章只记录 Agent 工具调用的最小闭环:声明工具 schema、接收 function_call、执行本地函数,并通过 function_call_output 继续下一次模型决策。
LeetCode 1282:用户分组,从相同容量分桶到满组即输出
相同组大小的人可以放在一起,但一个桶不一定就是一个组。用两个连续版本理解容量分组,并学会验证不唯一的答案。
LeetCode 49:字母异位词分组,从逐组比较到构造分组键
用 Python 从两个字符串的异位词判断出发,先写出逐组扫描的正确解,再推导排序键与字符计数键,理解哈希表分组为什么成立。
LeetCode 609:在文件系统中查找重复文件,从解析记录到按内容分组
重复的是文件内容,返回的却是完整路径。通过五个连续检查点,把输入解析、正确分组、哈希优化和平台提交串起来。
LeetCode 152:乘积最大子数组,从负数反例推导双状态动态规划
题目要求 给定整数数组 nums,找到一个乘积最大的非空连续子数组,返回这个最大乘积。 “连续”意味着选中的元素必须挨在一起,不能跳过中间的数;“非空”意味着至少选一个元素。只需返回乘积,不需要返回区间或区间长度。 输入:nums = [2, 3, -2, 4] 输出:6 解释:连续子数组 [2, 3] 的乘积最大,为 2 * 3 = 6。 输入:nums = [-2, 0, -1] 输出:0 解释:不能跳过 0,把 -2 和 -1 当作一个子数组;选择 [0] 可得到 0。 约束为 1 <= len(nums) <= 20000、-10 <= nums[i] <= 10。题目保证任意子数组的乘积都在 32 位有符号整数范围内。输入不会为空,但可能包含负数和零。 第 1 步:先枚举每个非空连续区间 对 [2, 3, -2, 4],如果直接把整个数组乘起来,得到的是 -48,而答案是前两个元素的乘积 6。因此,当前“把所有元素相乘”的做法缺少真正需要做的选择:从哪里开始,到哪里结束。 先不尝试猜哪个区间更好。最直接的正确办法,是枚举每一个合法区间,计算乘积,再保留最大的值。 固定左端点 left 后,让右端点 right 逐个向右移动。原来区间的乘积只需再乘上新加入的 nums[right],就能得到扩展后区间的乘积。这样每次扩展不必从头乘一遍。 加入这个完整的枚举函数: def max_product_bruteforce(nums: list[int]) -> int: answer = nums[0] for left in range(len(nums)): product = 1 for right in range(left, len(nums)): product *= nums[right] answer = max(answer, product) return answer 这里有两个不同的初始化: ...
LeetCode 279:完全平方数,从剩余和推导动态规划
题目要求 给定一个正整数 n,返回若干个正的完全平方数相加得到 n 时,所需的最少数量。完全平方数是整数的平方,例如 1 = 1²、4 = 2²、9 = 3²、16 = 4²。 和必须刚好等于 n。 同一个完全平方数可以使用任意次。 只返回数量,不需要返回具体分解;加数的先后顺序不影响答案。 约束:1 <= n <= 10^4。 输入:n = 12 输出:3 解释:12 = 4 + 4 + 4,需要 3 个完全平方数。 输入:n = 13 输出:2 解释:13 = 9 + 4,需要 2 个完全平方数。 下面使用 Python 3.9+ 逐步构建解法,适合已经理解函数、循环和基础递归,希望进一步理解动态规划的读者。 Step 1:先确定剩余和为 0 的答案 先拿最大的平方数,会不会让数量最少?对 n = 12,这一选择得到: 12 -> 先拿 9 -> 剩下 3 -> 再拿 1 + 1 + 1 总共:9 + 1 + 1 + 1,4 个 但是 4 + 4 + 4 只需要 3 个。当前的想法是“每次选不超过剩余和的最大平方数”,它在 12 上已经失败:单次拿得更多,不保证最后用得更少。 ...