Day 15 / 共 20 天 · 第 3 周 数据与评测(收尾)

跨模块组装图:六个模块怎么拼成一个后端

连续四天我们分别扎进了 data / evaluation / observability 三个模块。今天"退一步"看全局——精读 backend/api/api.goInit 函数,搞清楚 foundation → llm → prompt → data → evaluation ↔ observability 到底是怎么被一行行 Go 代码组装起来的,认识贯穿全仓库的"本地客户端"设计。

📍 你在整门课的位置 · 第 3 周 数据与评测(共 4 周 · 20 天)
D12 评测集+评估器 D13 实验主链路 D14 Trace 观测 D15 跨模块组装图 D16 前端壳与路由
L01

六个模块,谁先谁后

🤔 痛点:evaluation 模块要用 data 模块的数据集,data 又不认识 evaluation——它们怎么"认识"彼此的? DDD 的核心约束之一(AGENTS.md)是"模块间不直接互调"——但实验(Day 13)明明需要引用评测集,评测集底层又依赖 Dataset(Day 11/12),这不是矛盾吗?答案就藏在 backend/api/api.goInit 函数里。
💡 本质:谁也不 import 谁,靠"组装层"在最外面手动接线 每个模块只暴露一个 InitXxxHandler 函数,返回自己的"服务门面"。api.go 里的 Init 函数像个总电工,按顺序调用每个模块的初始化函数,把上一个模块的服务门面包装成"本地客户端"传给下一个模块——模块本身的代码里完全看不到其他模块的身影。

真实的初始化顺序(api.go:49-164):

func Init(ctx context.Context, ...) (*apis.APIHandler, error) {
	foundationHandler, _ := apis.InitFoundationHandler(...)                    // ① 用户/空间/鉴权,最底层
	llmHandler, _        := apis.InitLLMHandler(..., loauth.NewLocalAuthService(foundationHandler.AuthService))  // ② 依赖①
	promptHandler, _     := apis.InitPromptHandler(..., loruntime.NewLocalLLMRuntimeService(llmHandler.LLMRuntimeService), loauth..., lofile..., louser...)  // ③ 依赖①②
	dataHandler, _       := apis.InitDataHandler(..., loauth.NewLocalAuthService(foundationHandler.AuthService), louser...)  // ④ 依赖①
	evaluationHandler, _ := apis.InitEvaluationHandler(..., lodataset.NewLocalDatasetService(dataHandler.IDatasetApplication), lomanage..., loexecute..., loauth..., loruntime..., lotag.NewLocalTagService(dataHandler.TagService), ...)  // ⑤ 依赖①②③④
	observabilityHandler, _ := apis.InitObservabilityHandler(..., loevaluator.NewLocalEvaluatorService(evaluationHandler.EvaluatorService), loeval_set..., lodataset..., loexpt.NewLocalExperimentService(evaluationHandler.IExperimentApplication), ...)  // ⑥ 依赖①④⑤,且⑤反过来也要用⑥(见 L05)
	...
}
生活类比:装修一栋楼,先水电再装修 foundation(用户/权限)是"打地基";llm(模型接入)是"通电";prompt 是"装电器";data 是"搬家具";evaluation 是"请客人来试用";observability 是"装监控摄像头"——顺序错了,后面的活就没法干(先装电器总得先通电)。
L02

逐行读 Init 函数:一个真实的组装现场

重点看 evaluation 的初始化调用(api.go:102-125),它一次性接了四个其他模块的"本地客户端":

evaluationHandler, err = apis.InitEvaluationHandler(
	ctx, idgen, db, ckDB, cmdable, configFactory, mqFactory,
	lodataset.NewLocalDatasetService(dataHandler.IDatasetApplication, validator.KiteXValidatorMW),  // 借用 data 模块的数据集能力
	lomanage.NewLocalPromptManageService(promptHandler.PromptManageService),                        // 借用 prompt 模块管理能力
	loexecute.NewLocalPromptExecuteService(promptHandler.PromptExecuteService),                      // 借用 prompt 模块执行能力
	loauth.NewLocalAuthService(foundationHandler.AuthService),                                       // 借用 foundation 鉴权
	loruntime.NewLocalLLMRuntimeService(llmHandler.LLMRuntimeService),                                // 借用 llm 模块
	lotag.NewLocalTagService(dataHandler.TagService),                                                  // 借用 data 模块的标签能力
	func() observabilitytraceservice.Client { return lotrace.NewLocalTraceService(observabilityHandler.ITraceApplication) },  // 见 L05:闭包延迟拿
	...
)
```
💡 一眼就能数出 evaluation 模块"吃"了多少依赖 光是这一段就能看出 evaluation 直接依赖了 data(数据集+标签)、prompt(管理+执行)、foundation(鉴权)、llm(模型运行时)四个模块——光靠调用方式就能画出模块间的真实依赖关系,不需要另外看文档,这也是为什么 Day 15 要"精读 Init 函数":它是全仓库依赖关系的唯一真相来源(Single Source of Truth)。
L03

本地客户端:lodataset/lotrace 到底是什么

🤔 痛点:lodataset.NewLocalDatasetService(dataHandler.IDatasetApplication) 这行代码在干嘛? 看名字很像"调用一个远程的 dataset 微服务",但其实完全没有网络请求——它是全仓库最巧妙的设计之一。
💡 本质:用 Kitex 生成的"进程内 RPC 客户端",接口和真调微服务一模一样 打开 backend/loop_gen/coze/loop/data/lodataset/local_datasetservice.go(文件头就写着 Code generated by cozeloop. DO NOT EDIT.):
type LocalDatasetService struct {
	impl dataset.DatasetService // 直接持有 data 模块的实现,不是网络连接
	mds  endpoint.Middleware
}

func (l *LocalDatasetService) CreateDataset(ctx context.Context, req *dataset.CreateDatasetRequest, callOptions ...callopt.Option) (*dataset.CreateDatasetResponse, error) {
	chain := l.mds(func(ctx context.Context, in, out interface{}) error {
		arg := in.(*dataset.DatasetServiceCreateDatasetArgs)
		resp, err := l.impl.CreateDataset(ctx, arg.Req)   // 直接调用 Go 函数,没有序列化/网络
		result.SetSuccess(resp)
		return nil
	})
	...
}
生活类比:办公室内线电话 vs 打给外部客户 普通 Kitex RPC 客户端(比如 Day 14 里给 ClickHouse 用的那种)像"打长途电话"——要经过网络、可能超时、要处理连接。LocalDatasetService 这种"本地客户端"更像"办公室内线"——本质是同一个进程里的函数调用,但对外暴露的接口签名和真的打网络电话完全一样(同样的方法名、同样的参数结构、同样能加中间件)。这样代码里"调用另一个模块"永远是同一种写法,不用关心它部署在同进程还是拆成了独立微服务。
为什么带 callopt.Optionendpoint.Middleware 这些 RPC 概念:因为它是从同一套 IDL 生成的——Coze Loop 的 IDL 代码生成器既能生成"真的跨网络 Kitex 客户端",也能生成这种"进程内直接调用的本地客户端",两者代码结构几乎一致,只是 impl 字段的来源不同(一个是网络连接,一个是直接持有的对象引用)。
L04

为什么不直接 import 对方模块,图个方便?

⚠️ 如果 evaluation 直接 import backend/modules/data/domain/dataset/service 会怎样 短期看起来"省事"——少写一层包装。但长期会出现:① 循环依赖(data 未来想用 evaluation 的什么东西就直接死锁编译不过);② 边界失控(evaluation 可能绕过 data 的鉴权/校验逻辑,直接摸 data 的内部实现细节);③ 没法拆分(如果未来要把某个模块拆成独立微服务,代码里到处都是直接 import,重构成本极高。
💡 本地客户端模式的真正价值:现在单体、未来随时可拆 因为 evaluation 面向的始终是 dataset.DatasetService 这个接口(由 IDL 生成),而不是 data 模块的具体实现——今天用 lodataset.NewLocalDatasetService 传入进程内实现,未来如果要把 data 拆成独立微服务,只需要换成真的 Kitex 网络客户端,evaluation 那一行调用代码几乎不用改。这是"面向接口编程"在微服务/单体切换场景下最大的红利。

👶 小白问:那现在 Coze Loop 是单体还是微服务?

👨‍🏫 老师:从部署形态看是单体(一个 main.go 编译成一个二进制,Day 16 会看到 cmd/ 下还有单独的 consumer.go 进程)。但从代码组织看,它已经具备了"随时拆成微服务"的骨架——这种架构有时被称为"模块化单体"(Modular Monolith),是很多团队规模不到微服务量级但又想保留未来拆分空间时的折中选择。

L05

循环依赖是怎么破的:evaluation 和 observability 互相需要对方

🤔 痛点:evaluation 需要 observability 记录 Trace,observability 又需要 evaluation 的评估器/评测集数据来关联展示——这不就是循环依赖吗?api.go:118-124 这几行的写法很奇怪:
evaluationHandler, err = apis.InitEvaluationHandler(
	...
	func() observabilitytraceservice.Client {
		return lotrace.NewLocalTraceService(observabilityHandler.ITraceApplication)  // 这里引用了还没初始化的 observabilityHandler!
	},
	...
)
if err != nil { return nil, err }

observabilityHandler, err = apis.InitObservabilityHandler(   // observability 在 evaluation 之后才初始化
	...
	loevaluator.NewLocalEvaluatorService(evaluationHandler.EvaluatorService),   // 这里反过来用 evaluation 的服务
	...
)
💡 破解循环依赖的经典手法:闭包 + 延迟求值 注意传给 evaluation 的不是 lotrace.NewLocalTraceService(observabilityHandler.ITraceApplication)结果,而是一个返回该结果的函数func() observabilitytraceservice.Client {...})。Go 闭包会捕获 observabilityHandler 这个变量的引用而不是当时的值——这一行代码执行时 observabilityHandler 还是 nil,但真正调用这个闭包(拿到 Trace 客户端去上报)是在后面 observability 已经初始化完成之后。这是用"延迟求值"打破"初始化顺序上的循环依赖"的经典手法,在很多语言的依赖注入框架里都能看到类似技巧。
生活类比:先占个"待定"的位置,等确认了再填 就像开会前主持人说"稍后会由 XX 来发言"——此刻 XX 具体是谁可能还没确定,但"稍后调用这个环节"这件事已经安排好了。等真正轮到发言那一刻,主持人再去看"现在 XX 是谁",届时答案早已确定。
⚠️ 这个技巧的使用边界 闭包只能延迟"调用时刻",不能违反"真正调用那一刻,被引用的对象必须已经初始化完毕"这个前提——如果 evaluation 在自己的初始化过程中(不是运行时才调用)就立刻执行了这个闭包,那还是会拿到 nil 出错。所以这种手法要求"被依赖的初始化"发生在"实际使用"之前,顺序仍然重要,只是不要求在"传参那一刻"就完成。
L06

完整依赖图

foundation 基础 llm 模型接入 prompt 提示词 data 数据 evaluation 评测 observability 观测 粉色虚线 = observability 反向依赖 evaluation(靠闭包延迟求值破环)
六个模块的真实初始化依赖图(箭头 = "依赖方 → 被依赖方")
一个有趣的观察:依赖箭头基本是"自下而上"的,越基础的模块被越多人依赖(foundation 被四个模块直接或间接依赖),只有 evaluation↔observability 这一对是"你需要我、我也需要你",而这正是靠 L05 的闭包手法解开的唯一一处环。能用简单的"分层依赖"解决 90% 的问题,剩下 10% 的真实双向需求,用延迟求值兜底——这是一种务实的架构态度,不为了"理论上完全无环"而过度设计。
L07

今日小结 + 动手 + 预告

🧠 今天你应该能回答

  • 六个模块的初始化顺序是什么?为什么是这个顺序?
  • "本地客户端"(lodataset/lotrace 等)解决的核心问题是什么?
  • 为什么不让 evaluation 直接 import data 模块的代码?
  • evaluation 和 observability 互相依赖,是怎么用闭包破环的?
  • Coze Loop 现在是单体还是微服务?为什么说它是"模块化单体"?
🎵 记忆口诀模块互不 import,本地客户端搭桥,接口面向未来拆,闭包延迟破循环」——理解了今天这张图,你已经拿到了阅读这个仓库的"总钥匙",接下来几天的前端部分也会用到同一种分层思维。

✋ 动手 5 分钟(可选)

# 1. 完整读一遍组装入口,数一数六个模块各自的初始化函数名
cat backend/api/api.go

# 2. 找到所有"本地客户端"生成代码,看看有多少个模块间接口
ls backend/loop_gen/coze/loop/*/

# 3. 验证 evaluation 依赖了哪些模块(凭 import 就能看出来)
head -50 backend/modules/evaluation/application/experiment_app.go | grep "coze-dev/coze-loop"

# 4. 看看闭包破环那几行的完整上下文
sed -n '95,150p' backend/api/api.go
明天预告 · Day 16:后端的"骨架"看完了,明天切换战场——去 frontend/apps/cozeloop 看前端这个 React 单页应用的路由是怎么搭的,认识 Rush.js 这个前端 monorepo 管理工具。
← 上一天 Day 14 下一天 · 前端壳与路由 →