Python Monkey Patching:运行时改写规则的工程边界

Monkey patching 是 Python 社区里争议最持久的技术之一。反对它的人说它是”全局副作用的温床”;拥护它的人说它是”动态语言的正当权利”。两边说的都不错,但都没解决一个实际问题:你在什么情况下该用它,在什么情况下该克制

这篇文章不讲道德,只讲工程判断。


Python 对象模型为什么允许这件事

Monkey patching 能工作,根本原因是 Python 的对象模型是基于字典的。类和实例的属性查找都走 __dict__,方法也不例外。

1
2
3
4
5
6
7
8
9
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 之后可以直接修改它的属性:

1
2
3
4
5
6
7
8
9
10
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 调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 该模块的代码,包括你没意识到的间接依赖。测试之间的状态污染、并发环境下的竞态,都是这样来的。

1
2
3
# 危险:patch 之后没有恢复
import json
json.dumps = lambda x: "{}" # 全局生效,影响所有调用方

替换 __init____new__。构造函数的调用时机和参数复杂,替换之后行为很难预测,尤其是有继承的情况下。

在生产代码路径里做 patch。Monkey patching 放在测试代码里是工程实践,放在业务主路径里几乎都是架构债务的信号——通常意味着应该重新设计接口,而不是运行时打补丁。


实际工程里的决策框架

遇到”需要改变某个函数行为”的需求时,按这个顺序排查:

  1. 能改接口吗? 如果是自己的代码,把依赖显式化、用 DI 注入、写 subclass。这是最干净的解法。
  2. 能用 unittest.mock.patch 吗? 如果是测试场景,用标准库,不要自己写 patch 逻辑。
  3. 是第三方库的 bug 且短期内没有修复路径? 可以临时 patch,但一定要写清楚注释:哪个版本有问题、上游 issue 链接、预期在什么版本移除。
  4. 其他情况,基本上都不该用 monkey patching。

Python 的动态性让你可以做很多事,但”可以做”不等于”应该做”。Monkey patching 是语言给你的一把手术刀——场景对了很有用,场景不对留下的是后遗症。


Python Monkey Patching:运行时改写规则的工程边界
https://www.krli.org/2017/11/10/PythonHooking/
作者
李科燃
发布于
2017年11月10日
许可协议