《AI Agent 工程实战》1.3 仓库与工程骨架:monorepo + TS 包,CI 跑通 lint/test

本节把 ResearchCopilot 落成 monorepo:packages/researchcopilot 是 Python 服务包,packages/web 是 TS 前端包。给出 pyproject.toml 与 package.json 片段,在本机真跑 pytest、node --test、tsc --noEmit 并贴出输出,最后给出 GitHub Actions CI 形态。

1.3 仓库与工程骨架:monorepo + TS 包,CI 跑通 lint/test

1.2 给了我们一张四层架构图。但从图到代码之间,还差一件事:仓库长什么样。ResearchCopilot 是双栈的——后端是 Python,前端是 TypeScript(第 9 章要写最小聊天 UI)。如果把两者塞进两个独立仓库,共享的事件协议就得靠手工同步,改一处忘一处。所以本卷用 monorepo:一个仓库,两个语言包,一套 CI。

本节把 ResearchCopilot 推进到「有仓库、有 CI 门」:给出 monorepo 目录树与各目录职责,写出 pyproject.toml 与 package.json 的关键片段,在本机真跑一遍 pytest / node --test / tsc --noEmit,并给出 GitHub Actions 的 CI 形态。

1.3.1 为什么是 monorepo

monorepo 不是「把两个项目放一个文件夹」这么简单,它换来的是一组具体收益:

维度双仓库monorepo
事件协议手工同步,易漂移一份 schema,两栈共享
原子提交前后端分两次提交一次提交同时改两端
CI两套流水线一套,可只跑受影响的包
本地开发起两个仓库、两份环境一条命令起全部

代价也有:仓库变大、CI 变长。但对「一个产品、两个语言」这种形态,收益明显压过代价。本卷第 9 章要在 TS 侧复用 Python 侧定义的事件协议,如果分仓库,这件事会非常别扭。

1.3.2 目录树与职责

仓库的骨架如下(真实建出来、跑过的最小版本):

researchcopilot/
├── pyproject.toml                     # Python 包与服务依赖、pytest 配置
├── packages/
│   ├── researchcopilot/               # 后端 Python 包
│   │   └── researchcopilot/
│   │       ├── __init__.py            # 版本号
│   │       └── config.py              # Settings(连接串等)
│   └── web/                           # 前端 TS 包
│       ├── package.json               # node --test / tsc 脚本
│       ├── tsconfig.json              # erasableSyntaxOnly
│       └── src/
│           ├── events.ts              # 事件协议解析
│           └── events.test.ts         # node --test 用例
├── tests/                             # Python 测试
│   └── test_smoke.py                  # 冒烟:版本与默认配置
└── deploy/                            # 部署编排(第 16 章填)

四个目录,职责互不重叠:

目录职责关键约束
packages/researchcopilot/后端服务:配置、API、worker、存储访问只依赖 minagent 引擎,不改引擎
packages/web/前端:事件协议、聊天 UI只用可擦除 TS 语法(Node 26 直接跑)
tests/Python 侧的单元与集成测试走假模型,零密钥
deploy/Dockerfile、compose、CI 部署描述第 16 章前可以留空占位

注意 packages/researchcopilot/researchcopilot/ 这个「双层同名」结构——外层是包的根(放 pyproject.toml),内层是真正的 import 包。这是 Python 打包的惯例(src-less layout),pythonpath 指到外层目录即可。

1.3.3 pyproject.toml:依赖与测试配置

Python 侧的关键片段。版本号全部照本机实测口径写死,不用浮动范围——Agent 工程的依赖漂移代价太高,宁可显式钉版本:

[project]
name = "researchcopilot"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
    "pydantic==2.14.0",
    "pydantic-settings==2.15.0",
    "fastapi==0.143.0",
    "uvicorn==0.54.0",
    "psycopg[binary]==3.3.6",
    "redis==8.1.0",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["packages/researchcopilot"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

两个字段值得说明。pythonpath 让 pytest 无需安装包就能 import researchcopilot——本地开发时不必先 pip install -e .,省一步。testpaths 把收集范围钉死在 tests/,避免 pytest 去扫 packages/web/node_modules 里成千上万的文件。

1.3.4 package.json 与 tsconfig:可擦除语法

TS 侧的 package.json 只声明两个脚本,测试用 Node 内置的 node --test,类型检查用 tsc --noEmit:

{
  "name": "@researchcopilot/web",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --test",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": { "typescript": "7.0.2" }
}

tsconfig.json 里最关键的一项是 erasableSyntaxOnly:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "allowImportingTsExtensions": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

为什么要开 erasableSyntaxOnly?因为 Node 26 只支持「类型擦除」(type stripping),不支持 --experimental-transform-types。这意味着所有 TS 示例必须只用可擦除语法:不用参数属性(constructor parameter properties)、不用 enum、不用 namespace。开了这个开关,tsc 会提前把这类不可擦除的写法报错,避免你写出「类型检查能过、但 node file.ts 跑不起来」的代码。

1.3.5 真跑:Python 侧的收集与执行

骨架建好后,第一件要验证的事是「CI 能不能跑起来」。先看 Python 侧。一个最小冒烟测试 tests/test_smoke.py:

from researchcopilot import __version__
from researchcopilot.config import load


def test_version_is_set() -> None:
    assert __version__ == "0.1.0"


def test_defaults_point_at_local_containers() -> None:
    s = load()
    assert s.database_url.startswith("postgresql://")
    assert "15435" in s.database_url
    assert "16380" in s.redis_url
    assert s.max_steps == 8

它测两件事:包的版本号能被读到,以及默认配置指向本机的书稿专用容器(Postgres 15435、Redis 16380——见 BRIEF 的连接串口径)。用 venv 里的 Python 3.14.6 先跑收集:

python -m pytest --collect-only -q

真实输出:

tests/test_smoke.py::test_version_is_set
tests/test_smoke.py::test_defaults_point_at_local_containers

2 tests collected in 0.01s

收集到 2 个用例,说明 pythonpath 配置生效、包能被 import。再真跑一遍:

python -m pytest -q

真实输出:

..                                                                       [100%]
2 passed in 0.01s

两个点代表两个用例都通过。注意它没有连任何数据库——冒烟测试只验配置的字符串形态,不验连通性。真连库的集成测试留到第 5 章,那才是 psycopg 出场的地方。

1.3.6 真跑:TS 侧的测试与类型检查

前端包同样要能在 CI 里跑。src/events.ts 解析一条 SSE 帧:

export type AgentEvent =
  | { type: "token"; text: string }
  | { type: "tool_call"; name: string }
  | { type: "final"; content: string };

export function parseSseFrame(frame: string): AgentEvent | null {
  const line = frame.split("\n").find((l) => l.startsWith("data:"));
  if (!line) return null;
  return JSON.parse(line.slice(5).trim()) as AgentEvent;
}

src/events.test.ts 用 node:test 写两个用例:

import { test } from "node:test";
import assert from "node:assert/strict";
import { parseSseFrame } from "./events.ts";

test("解析 token 帧", () => {
  const ev = parseSseFrame('event: token\ndata: {"type":"token","text":"你好"}');
  assert.deepEqual(ev, { type: "token", text: "你好" });
});

test("空帧返回 null", () => {
  assert.equal(parseSseFrame("event: ping\n"), null);
});

注意 import 写的是 "./events.ts"——带 .ts 扩展名,因为 Node 的类型擦除模式要求显式扩展名(配合 tsconfig 的 allowImportingTsExtensions)。真跑 node --test(Node 26.5.0,直接执行 .ts,零构建):

node --test

真实输出:

✔ 解析 token 帧 (0.900792ms)
✔ 空帧返回 null (0.1085ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 122.441541

两个用例全绿。再跑类型检查:

npx tsc --noEmit

真实结果:退出码 0,无任何输出(--noEmit 表示只检查不产物;tsc 7.0.2 在全部通过时静默退出)。这里有个实测踩坑值得记下来:第一次跑 tsc 报了两条 TS2591,提示找不到 node:test / node:assert/strict——原因是 tsconfig 里缺 "types": ["node"],且需要装 @types/node(本机实测 26.6.5)。补上这两处后,退出码才变 0。类型检查器报「找不到模块」时,先怀疑 types 与 @types,别急着改代码。

1.3.7 GitHub Actions:一套 CI 跑两个包

本地跑通后,把它固化成 CI。下面这份 YAML 给出形态——注意:本节未在本机执行过这份 workflow,它需要推到 GitHub 后由 Actions 运行,这里只保证结构正确、步骤与上面真跑的命令一一对应:

name: ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  python:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - run: python -m pip install --index-url https://mirrors.ustc.edu.cn/pypi/simple -e ".[dev]"
      - run: python -m pytest -q

  web:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "26"
      - run: npm ci
        working-directory: packages/web
      - run: npm test
        working-directory: packages/web
      - run: npm run typecheck
        working-directory: packages/web

这份 workflow 有两个 job:python 跑 pytest,web 跑 node --test 与 tsc --noEmit。它们并行,互不阻塞。三条与本地实测的对应关系值得点出:

  • Python job 的 pip 源用的是中科大源,这是本机实测下来唯一可靠的源(PyPI 官方源与清华源在本机 pip 下都不可靠,阿里云源会因 pydantic wheel 元数据 404 失败)。
  • Node 版本锁 26,与本地一致;npm ci 保证依赖树可复现。
  • web job 的两个 run 都带 working-directory,因为 package.json 在子目录。

第 17 章会把这份 CI 扩展成完整的 CI/CD(加构建、加镜像、加灰度),第 12 章会在 python job 里插入一道质量门:评测分数下降就拦截合并。本节只把最基础的「lint/test 跑通」这道门立起来。

1.3.8 真实环境下的 CI 会怎样

这份骨架到了真实项目里,会往三个方向长:

  • 加缓存。pip 与 npm 都该缓存依赖目录,否则每次 CI 都从头下载,几分钟起步。Actions 的 cache 步骤是标配。
  • 按影响范围跑。monorepo 的常见优化:只改了 packages/web/ 就跳过 Python job。这需要一个「变更检测」步骤,第 17 章讲流水线时展开。
  • 加 lint。本节标题里写了 lint,但最小骨架只跑了 test 与 typecheck。真实的 lint(ruff / eslint)是紧接着要加的,它们和 test 一样,是「合并前必须绿」的门。

本节的自检清单:

  • pytest --collect-only 能收集到用例(真跑过)
  • pytest 通过(真跑过,2 passed)
  • node --test 通过(真跑过,2 pass)
  • tsc --noEmit 退出码 0(真跑过)
  • CI YAML 的步骤与本地命令一一对应(注明未在本机执行)

到这里,第 1 章就完整了:需求(1.1)、架构(1.2)、仓库与 CI(1.3)。骨架有了,下一章往里填第一块真实的砖——分层配置。想回看架构边界,翻 1.2 架构总览 。

阅读导航:上一节:1.2 架构总览:引擎 / 服务 / worker / 存储边界 · 下一节:2.1 分层配置:pydantic-settings 加载与校验 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距