Python Monkey Patching:运行时改写规则的工程边界
Monkey patching 是 Python 社区里争议最持久的技术之一。反对它的人说它是“全局副作用的温床”;拥护它的人说它是“动态语言的正当权利”。两边说的都不错,但都没解决一个实际问题:你在什么情况下该用它,在什么情况下该克制。
这篇文章不讲道德,只讲工程判断。
Python 对象模型为什么允许这件事
Monkey patching 能工作,根本原因是 Python 的对象模型是基于字典的。类和实例的属性查找都走 __dict__,方法也不例外。
class LLMClient:
def complete(self, prompt: str) -> str:
return call_api(prompt)
# 运行时替换方法
def mock_complete(self, prompt: str) -> str:
return "mocked response"
LLMClient.complete = mock_complete
这不是黑魔法,是语言规范。LLMClient.complete 只是 LLMClient.__dict__["complete"] 的一个绑定名称,重新赋值就是替换它。
模块也是同理。模块对象是 sys.modules 里的一个命名空间,import 之后可以直接修改它的属性:
import anthropic
# 替换模块里的函数
original_create = anthropic.Anthropic.messages.create
def patched_create(self, *args, **kwargs):
print(f"[trace] messages.create called: model={kwargs.get('model')}")
return original_create(self, *args, **kwargs)
anthropic.Anthropic.messages.create = patched_create
理解这个,才能理解 monkey patching 的边界在哪里——它操作的是运行时的命名空间,不是源码,所以影响范围取决于你修改的是谁的 __dict__。
有价值的用法
1. 测试隔离:截断真实的 LLM 调用
AI 应用的测试有一个特殊困难:LLM 调用有延迟、有费用、结果不确定。直接跑集成测试成本太高,完全 mock 又测不到真实逻辑。
Monkey patching 可以在不改动任何业务代码的前提下替换掉 SDK 调用:
import pytest
from unittest.mock import MagicMock, patch
@pytest.fixture
def mock_llm():
with patch("anthropic.Anthropic.messages") as mock_messages:
mock_messages.create.return_value = MagicMock(
content=[MagicMock(text="test response")]
)
yield mock_messages
def test_agent_summarize(mock_llm):
agent = SummaryAgent()
result = agent.run("summarize this document")
assert "test response" in result
mock_llm.create.assert_called_once()
unittest.mock.patch 本质上就是 monkey patching 的标准化封装——它帮你管理替换和恢复的生命周期,避免状态泄漏到其他测试。
2. 注入可观测性:不侵入业务代码的 tracing
在 Agent 系统里,你经常需要记录工具调用的入参、出参、耗时,但又不想让 tracing 代码侵入工具实现本身。
Monkey patching 可以在运行时为任意函数注入 instrumentation:
import functools
import time
from typing import Callable
def trace(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
record_span(fn.__name__, elapsed, status="ok")
return result
except Exception as e:
elapsed = time.perf_counter() - start
record_span(fn.__name__, elapsed, status="error", error=str(e))
raise
return wrapper
# 在初始化时统一注入,不改动工具实现
for name, tool in tool_registry.items():
tool_registry[name] = trace(tool)
这比在每个工具函数里手写 span.start() / span.end() 要干净得多。修改集中在一处,工具函数本身保持纯粹。
3. 第三方 SDK 的行为修正
有时候第三方库的行为有 bug,或者你需要在它的某个方法里加特殊逻辑,但又等不到上游修复。
这是 monkey patching 最有争议、但也最实际的用法。判断是否该这样做的标准只有一个:是否有更短的路径。
如果可以 fork、可以提 PR 并在内部打补丁包、可以用 subclassing 覆盖,优先走那些路。Monkey patching 第三方库的代价是:升级版本时补丁可能静默失效,且不容易被发现。
会出问题的用法
在模块级别 patch 但不恢复。修改 sys.modules 里的对象会影响所有 import 该模块的代码,包括你没意识到的间接依赖。测试之间的状态污染、并发环境下的竞态,都是这样来的。
# 危险:patch 之后没有恢复
import json
json.dumps = lambda x: "{}" # 全局生效,影响所有调用方
替换 __init__ 或 __new__。构造函数的调用时机和参数复杂,替换之后行为很难预测,尤其是有继承的情况下。
在生产代码路径里做 patch。Monkey patching 放在测试代码里是工程实践,放在业务主路径里几乎都是架构债务的信号——通常意味着应该重新设计接口,而不是运行时打补丁。
实际工程里的决策框架
遇到“需要改变某个函数行为”的需求时,按这个顺序排查:
- 能改接口吗? 如果是自己的代码,把依赖显式化、用 DI 注入、写 subclass。这是最干净的解法。
- 能用
unittest.mock.patch吗? 如果是测试场景,用标准库,不要自己写 patch 逻辑。 - 是第三方库的 bug 且短期内没有修复路径? 可以临时 patch,但一定要写清楚注释:哪个版本有问题、上游 issue 链接、预期在什么版本移除。
- 其他情况,基本上都不该用 monkey patching。
Python 的动态性让你可以做很多事,但“可以做”不等于“应该做”。Monkey patching 是语言给你的一把手术刀——场景对了很有用,场景不对留下的是后遗症。
← 所有文章