docling API 设计规范:默认参数陷阱、Keyword-Only 参数与 ThreadPoolExecutor 正确使用模式
本文基于 docling 仓库内 dignified-python 技能参考文档 api-design.md 展开,系统讲解函数默认参数值的风险边界、5 参数以上函数的 keyword-only 强制规范、ThreadPoolExecutor.submit() 的参数传递陷阱,以及"拒绝投机性测试基建"的四条 API 设计准则;并结合 docling 源码中 VLM API 引擎、LaTeX 后端与批量转换器的真实线程池用法,展示这些规范在生产代码中的落地形态。读完后,你可以在为 docling 或类似 Python 项目新增函数、并发任务与测试 Fake 时,做出与项目既有代码风格一致的 API 设计决策。
文档定位:何时需要参考这份设计规范
该文档位于 .agents/skills/dignified-python/references/advanced/api-design.md,是 dignified-python 技能包中"进阶"参考资料之一,其元数据明确了触发时机:
Read when: Adding default parameters, functions with 5+ params, using ThreadPoolExecutor
也就是说,当你准备给函数加默认参数值、编写参数超过 5 个的函数、或使用 ThreadPoolExecutor 做并发调度时,应当先对照本文的准则。它涵盖四个主题:
- 默认参数值(Default Parameter Values)的危险性与豁免场景;
- 复杂函数(5+ 参数)的 Keyword-Only 强制规范;
ThreadPoolExecutor.submit()与 keyword-only 函数的兼容模式;- 投机性测试基建与投机性测试的禁止准则。
以下逐条展开,并在 docling 源码中寻找对应证据。
一、默认参数值是一种"危险的便利"
文档的核心立场非常直接:除非绝对必要,避免使用默认参数值(Avoid default parameter values unless absolutely necessary),它们是重要的 bug 来源。
适用范围澄清:定义 vs. 调用
文档特别用一段 Scope 说明划清了规则的边界,这一点在评审代码时极易误判:
- 规则适用于函数定义,例如
def foo(bar: bool = False); - 规则不适用于函数调用时恰好传了一个名为
default的关键字参数,例如click.confirm(default=True)——这是显式提供值,而非创建默认参数值,完全合法。
默认参数为什么危险
文档给出了四条具体理由,值得逐条理解:
- 静默的错误行为(Silent incorrect behavior):调用方忘记传参时,得到一个"能跑但不对"的结果,编译器与运行时都不报错;
- 隐藏耦合(Hidden coupling):默认值隐含了一个假设,而这个假设对所有调用方未必成立;
- 审计困难(Audit difficulty):很难逐一验证所有调用点都使用了正确的取值;
- 重构隐患(Refactoring hazard):给函数新增一个带默认值的参数,不会让任何现有调用点报错,问题被无声掩盖。
文档用一个编码(encoding)示例演示了这种静默失败:
# DANGEROUS: Default that might be wrong for some callers
def process_file(path: Path, encoding: str = "utf-8") -> str:
return path.read_text(encoding=encoding)
# Caller forgets encoding, silently gets wrong behavior for legacy file
content = process_file(legacy_latin1_file) # Bug: should be encoding="latin-1"
# SAFER: Require explicit choice
def process_file(path: Path, encoding: str) -> str:
return path.read_text(encoding=encoding)
# Caller must think about encoding
content = process_file(legacy_latin1_file, encoding="latin-1")
对于 docling 这类处理 PDF、LaTeX、Office、EBCDIC 等多格式文档转换器的项目,这类"隐式假设"恰恰是高频踩坑区——文本编码、页面方向、表格结构选项等参数一旦给错默认值,输出会"看起来正常"但内容已失真。
发现"从不被覆盖"的默认值时,直接删除参数
文档的第二段实操准则:如果所有调用点都显式传入同一个值(等价于"默认值从未被用到"),说明这个参数已经退化为常量,应当把参数从签名中移除,把行为固化到函数体内:
# If every call site uses the default...
activate_worktree(ctx, repo, path, script, "up", preserve_relative_path=True) # Always True
activate_worktree(ctx, repo, path, script, "down", preserve_relative_path=True) # Always True
# CORRECT: Remove the parameter entirely
def activate_worktree(ctx, repo, path, script, command_name) -> None:
# Always preserve relative path - it's just the behavior
...
三种可接受的默认值场景
文档并非一刀切,列出了默认值的三种豁免场景:
- 真正可选的行为:默认值对 95% 以上调用方都是正确的;
- 向后兼容:给既有公共 API 新增参数时的临时手段(文档明确标注 temporary);
- 测试辅助函数:文档原文提到,存在于测试工具目录(如其原始上下文的
tests/test_utils/)中、用于减少测试样板代码的 helper 函数被明确豁免——这类 helper 常常封装复杂的构造器(如原文举例的format_plan_header_body),"多默认参数"本身就是它们的用途而非代码异味。
文档同时给出评审三连问:
- 所有调用点真的都想要这个默认值吗?
- 调用方忘记传这个参数会不会造成 bug?
- 是否存在一种更安全的设计,把选择变成显式的?
默认结论:要求显式传值;消除从未被使用的默认值。
二、5 参数以上函数必须使用 Keyword-Only 参数
文档的硬性规则是:参数达到 5 个或以上的函数 MUST 使用 keyword-only arguments,在第一个位置参数之后用 * 分隔符在语言层面强制后续参数只能按名传递:
# CORRECT: Keyword-only after first param
def fetch_data(
url,
*,
timeout: float,
retries: int,
headers: dict[str, str],
auth_token: str,
) -> Response:
...
# Call site is self-documenting
response = fetch_data(
api_url,
timeout=30.0,
retries=3,
headers={"Accept": "application/json"},
auth_token=token,
)
# WRONG: All positional parameters
def fetch_data(
url,
timeout: float,
retries: int,
headers: dict[str, str],
auth_token: str,
) -> Response:
...
# Call site is unreadable - what do these values mean?
response = fetch_data(api_url, 30.0, 3, {"Accept": "application/json"}, token)
纯位置参数调用 fetch_data(api_url, 30.0, 3, {"Accept": ...}, token) 在调用点完全不可读——这些值各自代表什么?而 keyword-only 调用点在语法层面就是自文档化的。
四项例外
文档同样明确了规则的边界,避免过度机械执行:
self:永远是位置参数(Python 语言要求);ctx/ 上下文对象:可以作为第一个参数保持位置传递(约定俗成);- ABC / Protocol 方法:豁免,避免强制所有实现类同时修改签名;
- Click 回调:Click 框架会注入参数,遵循 Click 自身的约定即可。
文档给出的标准形态示例:ctx 保持位置参数,其余全部 keyword-only:
# CORRECT: ctx stays positional, rest are keyword-only
def build_report(
ctx: AppContext,
*,
project_id: str,
output_path: Path,
include_drafts: bool,
) -> Report:
...
docling 源码中的印证
在 docling 仓库中检索函数签名内的 keyword-only 分隔符 *,可以确认这一规范与项目现状高度吻合:docling/backend/pdf_backend.py、docling/backend/msword_backend.py、docling/datamodel/settings.py、docling/models/base_ocr_model.py 等多处均存在含 * 分隔符的函数定义。以参数众多、调用点密集的 pipeline_options 与后端选项类为例,keyword-only 化能确保"选项名=语义"的显式调用风格贯穿全仓库。
三、ThreadPoolExecutor.submit() 的坑:keyword-only 函数需要 lambda 包装
这是四条准则中最容易在真实并发代码里踩中的一条。ThreadPoolExecutor.submit() 会按位置顺序把参数转发给被调函数;如果被调函数在第一个参数之后声明了 keyword-only 参数,直接 submit 会因签名不匹配而失败。正确做法是用 lambda 包装,让线程内执行的是完整的命名调用:
# WRONG: submit() passes args positionally - fails with keyword-only functions
future = executor.submit(fetch_data, url, timeout, retries, headers, token)
# CORRECT: Lambda enables keyword arguments
future = executor.submit(
lambda: fetch_data(
url,
timeout=timeout,
retries=retries,
headers=headers,
auth_token=token,
)
)
docling 源码中的三种线程池提交形态
docling 仓库中有多处 ThreadPoolExecutor 的实战用法,恰好展示了"如何避开这个坑"的几种工程化变体。
形态一:闭包函数代替裸函数。 VLM API 引擎 docling/models/inference_engines/vlm/api_openai_compatible_engine.py 在处理一批图片时,并不 submit 一个带长参数列表的独立函数,而是在外层作用域定义闭包 _process_single_input(其内部捕获 API 客户端等上下文,返回 VlmEngineOutput),随后只 submit 单一数据参数:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(_process_single_input, input_data)
for input_data in input_batch
]
outputs = [future.result() for future in futures]
从源码结构看,闭包把"复杂上下文"收敛到定义处,把"随任务变化的数据"收敛到 submit 处,天然规避了位置参数数量/顺序错配问题。max_workers 的取值也有讲究:min(self.options.concurrency, len(input_batch))——并发度不超过批内任务数,避免空转线程。
形态二:被调函数全位置签名。 LaTeX 后端的 TikZ 异步渲染 docling/backend/latex/handlers/environments.py 中,render_task 闭包声明为 5 个全位置参数(engine, raw_tikz, picture_item, preamble, source_root),因此 self._tikz_executor.submit(render_task, self._tectonic_engine, tikz_raw, pic, preamble, source_root) 可以安全地按位置转发——这正对应准则的逆命题:只要被 submit 的函数签名没有 keyword-only 段,位置转发就是合法的。该执行器在 docling/backend/latex/backend.py 中按 max_workers=workers 创建,渲染失败时会降级为原始 TikZ 代码而非中断整个文档转换。
形态三:pool.map + partial 固化关键字参数。 批量文档转换器 docling/document_converter.py 采用另一种组合:functools.partial 把关键字参数 raises_on_error 固化进 process_func,再交给 pool.map 按位置分发单个文档输入:
process_func = partial(
self._process_document, raises_on_error=raises_on_error
)
with ThreadPoolExecutor(
max_workers=settings.perf.doc_batch_concurrency
) as pool:
for item in pool.map(process_func, input_batch):
yield item
三种形态殊途同归:"随任务变化的数据"走位置传递,"固定配置"在提交前就通过闭包/partial/lambda 收敛为命名调用。这是文档第三条准则在 docling 工程实践中的具体化。
四、拒绝投机性测试基建与投机性测试
文档的后半部分针对测试代码立下两条禁令。
4.1 不要给 Fake"以防万一"加参数
准则:Don't add parameters to fakes "just in case" they might be useful for testing. Fake 应当镜像生产接口;为"将来某个测试可能用到"而添加的配置旋钮,只会制造死代码和虚假复杂度:
# WRONG: Test-only parameter that's never used in production
class FakeGitHub:
def __init__(
self,
prs: dict[str, PullRequestInfo] | None = None,
rate_limited: bool = False, # "Might test this later"
) -> None:
self._rate_limited = rate_limited # Never set to True anywhere
# CORRECT: Only add infrastructure when you need it
class FakeGitHub:
def __init__(
self,
prs: dict[str, PullRequestInfo] | None = None,
) -> None:
...
文档给出的判定方法很可操作:如果 grep 显示某参数只在测试文件里被传入,且那些测试验证的是"假想场景"而非真实生产行为,就同时删除该参数和对应测试。
docling 仓库的 tests/fakes/ 目录是"Fake 镜像生产接口"准则的正面示范。以 tests/fakes/kserve_v2.py 为例,其模块文档说明:Fake 的响应体直接复用仓库生产代码中的 KserveV2ModelMetadataResponse 与 KserveV2InferResponse 数据模型("so the fake cannot drift from the shapes the client validates against"),输出张量则由每个测试按需注册 handler 注入——"keeps the fake a transport rather than a reimplementation of any particular model"。Fake 只保留成为"传输层替身"所必需的最小接口,没有任何投机性旋钮。
4.2 禁止为未来功能写测试
# FORBIDDEN: Tests for future features
# def test_feature_we_might_add():
# pass
# CORRECT: TDD for current implementation
def test_feature_being_built_now():
result = new_feature()
assert result == expected
测试服务于正在被构建的实现,而不是计划中的功能。这条规则与"投机性 Fake 参数"一脉相承:任何只为假想未来服务的代码都是当前代码库的负债。
五、决策清单:动手前的自检
文档末尾给出两份可直接用于 Code Review 的决策清单,完整继承如下。
在添加默认参数值之前:
- [ ] 95% 以上的调用方是否真的想要这个默认值?
- [ ] 忘记传这个参数是否会导致隐蔽 bug?
- [ ] 是否存在一种更安全的设计,把选择变成显式的?
- [ ] 如果这个默认值在任何地方都从未被覆盖,这个参数还有存在的必要吗?
默认做法:要求显式传值;消除从未被使用的默认值。
在添加 5 参数以上的函数之前:
- [ ] 我是否在第一个参数(或
ctx)之后加了*? - [ ] 是否只有
self/ctx是位置参数? - [ ] 这是否是 ABC/Protocol 方法?(若是,则豁免本规则)
- [ ] 如果使用
ThreadPoolExecutor.submit(),我是否使用了 lambda 包装?
默认做法:第一个参数之后的所有参数都应为 keyword-only。
小结
这份 api-design 参考文档的四条准则,共同指向同一设计哲学:让调用点自文档化,让假设显式化。默认参数把"选择"藏进函数定义,keyword-only 把"选择"摊回调用点;submit() 的 lambda 包装把"参数如何传递"的责任留在提交方;拒绝投机性测试基建则保证每一行代码都服务于当前行为。对照 docling 源码可以看到,无论是 VLM API 引擎的批量并发(api_openai_compatible_engine.py)、LaTeX 后端的 TikZ 异步渲染(environments.py)、批量文档转换(document_converter.py),还是 tests/fakes/ 下紧贴生产数据模型的 Fake 实现,这些准则都有对应的工程落点。在为 docling 新增函数签名、并发任务或测试替身时,按文中两份决策清单逐项自检,就能保持与项目既有 API 风格的一致性。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00