主题
Harness 与 App 分层边界
本章目标:
- 讲清 DeerFlow 后端为何拆成
deerflow.*(可发布 harness)与app.*(不发布应用层)双层,以及二者的目录归属。- 理解依赖单向规则:
app → deerflow允许,deerflow → app禁止,以及app/__init__.py、deerflow/__init__.py等 import 约定。- 掌握
tests/test_harness_boundary.py如何用 AST 扫描在 CI 中强制这条边界,违规会怎样导致 CI 失败。
TL;DR
DeerFlow 后端被切成两层:packages/harness/deerflow/(可独立打包发布的 agent 框架 deerflow-harness)与 app/(不发布的 FastAPI Gateway + IM 通道应用层)。依赖方向严格单向——应用层可以 from deerflow... 引用框架,但框架代码绝不允许 from app...。这条边界不靠人工 review,而是由 tests/test_harness_boundary.py 用 Python ast 模块静态扫描 harness 包内每个 .py 文件强制执行,在 backend-unit-tests.yml 的 make test 中随每个 PR 自动运行;一旦 harness 出现对 app.* 的 import,该测试断言失败,CI 红灯阻止合并。这样做是为了让 harness 保持可发布、可独立复用,与具体的 Web 应用形态解耦。
Overview
为什么要费力维护一条"框架不能反向依赖应用"的边界?核心动机是可发布性与解耦。
deerflow-harness 是一个有独立 pyproject.toml、独立 name = "deerflow-harness"、独立依赖清单的 Python 包 backend/packages/harness/pyproject.toml:1-39,通过 hatchling 只把 deerflow 包打进 wheel backend/packages/harness/pyproject.toml:51-56。它包含构建和运行 agent 所需的一切:agent 编排、工具、沙箱、模型、MCP、skills、配置。它本身不依赖 FastAPI,也不知道"Gateway"或"IM 通道"的存在。
如果让 harness 反向 import app.*(例如某个 agent 工具偷偷 from app.gateway.routers.uploads import ...),会发生三件坏事:
- 打包污染:
app/不在 wheel 内,发布出去的deerflow-harness会因找不到app模块在导入时崩溃。 - 依赖循环:
app依赖deerflow(deer-flow的dependencies第一项就是deerflow-harnessbackend/pyproject.toml:7-8),若deerflow又依赖app,就形成环,无法把 harness 当作纯框架复用。 - 解耦失效:嵌入式
DeerFlowClient走的是同一套deerflow模块、不带 FastAPI;一旦 harness 牵连 app,就再也无法在无 HTTP 服务的场景下嵌入使用。
backend/docs/rfc-extract-shared-modules.md 的设计原则一节把这条规则写成显式约束:共享业务逻辑必须下沉到 harness 层(纯逻辑、无 FastAPI 依赖),Gateway 做 HTTP 适配、Client 做本地适配,"满足 test_harness_boundary.py 约束:harness 永不 import app" backend/docs/rfc-extract-shared-modules.md:42-46。该 RFC 在"备选方案"中明确否决了"把所有逻辑放进 client、让 Gateway import 它"的做法,理由正是会破坏 harness/app 边界 backend/docs/rfc-extract-shared-modules.md:170-175。
Architecture
两层在仓库中的物理位置与包归属:
| 层 | 物理目录 | import 前缀 | 包名 / 是否发布 | Source |
|---|---|---|---|---|
| Harness(框架层) | backend/packages/harness/deerflow/ | deerflow.* | deerflow-harness,通过 hatchling 打包发布 | backend/packages/harness/pyproject.toml:1-56 |
| App(应用层) | backend/app/ | app.* | 属于 deer-flow 包,不单独发布 | backend/pyproject.toml:1-24 |
| 边界测试 | backend/tests/test_harness_boundary.py | — | 仅在 CI / make test 运行 | backend/tests/test_harness_boundary.py:37-46 |
Harness 层(deerflow.*)目录包含:agents/(LeadAgent、中间件、memory、ThreadState)、sandbox/、subagents/、tools/、mcp/、models/、skills/、config/、community/、reflection/、runtime/、persistence/、uploads/、utils/、client.py。App 层(app.*)仅包含两块:app/gateway/(FastAPI Gateway API 与路由)、app/channels/(Feishu / Slack / Telegram / DingTalk IM 集成)。
uv workspace 把 harness 声明为子成员包,源解析为本地 workspace 包(开发期可编辑安装),而 deer-flow 把 deerflow-harness 列为首要依赖:
[tool.uv.workspace] members = ["packages/harness"]backend/pyproject.toml:46-47[tool.uv.sources] deerflow-harness = { workspace = true }backend/pyproject.toml:49-50dependencies = ["deerflow-harness", "fastapi>=0.115.0", ...]backend/pyproject.toml:7-9
langgraph.json 把图入口指向 harness 的 deerflow.agents:make_lead_agent,而 auth 路径指向 app 层的 ./app/gateway/langgraph_auth.py:auth——这正体现"app 引用 harness 入口,harness 不知道 app"的方向。
Components / Subsystems
围绕这条边界,有四个协作部件:
harness 包定义(
packages/harness/pyproject.toml):声明name = "deerflow-harness"、requires-python = ">=3.12"、完整的 LangGraph / LangChain / 模型 / 沙箱依赖,以及ollama/postgres/pymupdf可选依赖组 backend/packages/harness/pyproject.toml:1-49。[tool.hatch.build.targets.wheel] packages = ["deerflow"]决定 wheel 内只有deerflow顶层包 backend/packages/harness/pyproject.toml:55-56。包入口
__init__.py:backend/packages/harness/deerflow/__init__.py与backend/app/__init__.py均为空文件(0 字节),仅起到声明 Python 包的作用,不做任何 re-export,因此 import 约定靠子模块全路径(from deerflow.agents import ...、from app.gateway.app import app)而非顶层快捷别名。app 层消费者:
app.gateway.*与app.channels.*大量通过全路径 import 引用 harness,例如from deerflow.config.app_config import get_app_config、from deerflow.runtime.user_context import set_current_user、from deerflow.config.paths import get_pathsbackend/app/gateway/services.py:22-23、backend/app/channels/feishu.py:15-17。这些都是被允许的方向。边界守卫测试:
tests/test_harness_boundary.py是这条规则的唯一强制点。它不依赖运行时,纯静态分析:遍历 harness 包所有.py,用ast解析每个 import,只要发现模块名等于app或以app.开头即记为违规 backend/tests/test_harness_boundary.py:37-46。
Data Flow
下面是 test_harness_boundary.py 在一次 CI 运行中扫描 AST、检测违规的完整流程:
CI 触发链:.github/workflows/backend-unit-tests.yml 在 push 到 main 与每个非草稿 PR 时运行 .github/workflows/backend-unit-tests.yml:3-7,在 backend 工作目录执行 make test .github/workflows/backend-unit-tests.yml:38-40,而 make test 即 PYTHONPATH=. uv run pytest tests/ -v(见 backend/Makefile),边界测试随整个 tests/ 套件一并被收集执行。
Implementation Details
整个防火墙的核心不到 20 行——ast 收集 import 与断言部分:
python
def _collect_imports(filepath: Path) -> list[tuple[int, str]]:
source = filepath.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(filepath))
except SyntaxError:
return []
results: list[tuple[int, str]] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
results.append((node.lineno, alias.name))
elif isinstance(node, ast.ImportFrom):
if node.module:
results.append((node.lineno, node.module))
return resultsbackend/tests/test_harness_boundary.py:18-34
解读关键点:
HARNESS_ROOT锁定为packages/harness/deerflow,扫描范围严格限定在 harness 包内 backend/tests/test_harness_boundary.py:13。BANNED_PREFIXES = ("app.",),匹配规则是module == "app"(去掉末尾点)或module.startswith("app.")backend/tests/test_harness_boundary.py:15 backend/tests/test_harness_boundary.py:42。- 同时覆盖
import app.x(走ast.Import的alias.name)和from app.x import y(走ast.ImportFrom的node.module)两种形式 backend/tests/test_harness_boundary.py:28-33。 - 静态分析的优点:无需真实导入(避免触发副作用与循环导入),只看源码语法树;无法解析的文件(
SyntaxError)返回空列表跳过,而非误报。 - 断言信息会列出
相对路径:行号 imports 模块名,便于定位 backend/tests/test_harness_boundary.py:43-46。
一个真实细节:该检测只匹配模块路径前缀,因此 harness 代码里出现注释或字符串中的 "app config"(如 aio_sandbox_provider.py、file_conversion.py 里的 docstring)不会被误判——它只解析 AST 的 import 节点,不做文本扫描。
Common Pitfalls / Tips
- 违规直接让 CI 失败、阻止合并:
test_harness_boundary.py随make test在每个 PR 跑;任意 harness 文件新增from app.../import app...都会让test_harness_does_not_import_app断言失败,CI 红灯。修复方式是把需要共享的逻辑下沉为 harness 内的纯模块(参考 RFC 的deerflow.skills.installer/deerflow.uploads.manager模式 backend/docs/rfc-extract-shared-modules.md:59-116),让 Gateway/Client 各自做适配,而不是让 harness 反向依赖。 - 顶层
__init__.py是空的:不要指望from deerflow import xxx或from app import xxx这类顶层别名——harness 与 app 的__init__.py均为 0 字节,必须走完整子模块路径 import。 - 方向是单向不是双向:
app → deerflow是常态且被允许(app/gateway、app/channels大量这样做),只有deerflow → app被禁止。把共享代码"为了省事"放进app再让 harness 引用,是该 RFC 明确否决的反模式 backend/docs/rfc-extract-shared-modules.md:170-175。 - 测试套件的循环导入对策:
tests/conftest.py通过在任何测试触发前向sys.modules注入deerflow.subagents.executor的 mock 来打破生产代码里deerflow.subagents.__init__ → executor → agents.thread_state → ... → executor的循环 backend/tests/conftest.py:26-45;这与边界测试本身无关(边界测试是纯静态、不真正导入 harness),但说明 harness 内部模块图较密,新增跨子系统 import 时要留意循环风险。 - harness 自身依赖要写进 harness 的 pyproject:harness 新增第三方依赖应加到
packages/harness/pyproject.toml的dependencies,而不是backend/pyproject.toml——否则发布出去的deerflow-harnesswheel 会缺依赖 backend/packages/harness/pyproject.toml:6-39。
References
- backend/tests/test_harness_boundary.py — AST 导入防火墙实现
- backend/packages/harness/pyproject.toml —
deerflow-harness包定义与 hatchling 打包配置 - backend/pyproject.toml —
deer-flow包对 harness 的依赖与 uv workspace 声明 - .github/workflows/backend-unit-tests.yml — CI 触发与
make test步骤 - backend/docs/rfc-extract-shared-modules.md — 共享模块下沉设计与依赖方向原则
- backend/tests/conftest.py — 测试套件循环导入对策
- backend/CLAUDE.md — Harness / App Split 章节(线索,已回源码核实)
Related Pages
| 页面 | 关系 |
|---|---|
| 01-项目概览.md | 上游背景:DeerFlow 整体定位与可发布 harness 的目标,本章是其分层规则的细化 |
| 03-系统整体架构.md | 平行视角:系统整体架构(Gateway / Frontend / Runtime),本章聚焦其中后端两层的边界约束 |
| 13-Gateway-API与路由体系.md | 下游消费者:app.gateway 是"app → deerflow"方向的典型应用层,展示边界的实际遵守方式 |
| 36-测试策略与质量保障.md | 延伸:本章的 test_harness_boundary.py 导入防火墙在 36 章作为 CI 强制项详述 |