07 工程化、部署与评估深挖问答
怎么用:背「口述回答」那一段——它是第一人称、可以直接说出口的话,其余部分(面试官想考 / 讲解与备注 / 代码依据 / 追问链 / 别踩的雷)是给你在面试前一晚快速扫的弹药。 怎么用:所有
文件:行号都是真实位置,面试官让你投屏指代码时可以直接跳。 怎么用:全文没有一个编造的数字。凡是仓库里查不到的实测性能/效果指标,都明确写了「仓库中未找到」并给了诚实话术——这是这份文档最重要的一条纪律,面试官一追问就穿帮。
一、1 分钟讲清工程化
(面试官说"简单介绍下这个项目的工程化",就背这一段,约 1 分钟)
「这个项目叫 BinRag,是一个 Go 写的企业级文档 RAG 问答系统。工程化上我做了四件事。
第一,单二进制 + 双形态。 前端是 Vue 3 加 Vite,构建产物直接输出到 internal/webui/dist,然后用 go:embed 打进 Go 二进制。所以 Web 版就是一个可执行文件,镜像里只有 alpine 加这一个二进制,没有 Node、没有 nginx。更关键的是我把后端装配抽成了 internal/app 这个包,cmd/server 和 cmd/desktop 都调它的 app.New,区别只有一行——Web 版监听 :8085,桌面版用 Wails v3 把同一个 app.Router() 挂到一个 127.0.0.1:0 的随机端口上,然后让窗口加载这个地址。这样前端代码零改动、同源、没有 CORS、不依赖 Wails 的 IPC。
第二,容器化。 三阶段 Dockerfile:Node 22 构建前端、Go 1.26 编译二进制、Alpine 3.20 做运行时。编译用 CGO_ENABLED=0 加 -trimpath -ldflags="-w -s",所以 Web 版能交叉编译 5 个平台,CI 里矩阵一出就是 linux/darwin/windows 的安装包。镜像里不含任何配置,配置靠 volume 挂载,没挂就启动失败,这是故意的 fail-fast。
第三,CI/CD。 GitHub Actions 三条工作流:ci.yml 做 gofmt + vet + 单测 + 前端类型检查和 vitest;docker-publish.yml 多架构构建推到 ghcr;release.yml 在打 v* tag 时交叉编译 Web 版、在 macOS/Windows runner 上分别出 .dmg 和 .zip,然后发 Release。
第四,评估闭环。 这个是重点。我有两套评估:Go 侧的 cmd/eval 做进程内的 Recall@K 和 LLM-as-Judge;另外单拉了一个 Python 的 RAGAS 微服务,Go 用反向代理加内部 token 调它,做 RAGAS 标准的四指标。为什么要两套——因为 Recall@K 需要进程内拿到完整候选集,走 HTTP 拿不到,所以留 Go;而 RAGAS 生态在 Python,生成侧的四个指标天然是黑盒的,可以走 HTTP。两边共用同一份数据集格式。
这四件事合起来就是:一个二进制能部署成 Web 也能打包成桌面,一条 tag 出全平台产物,改配置不重启,效果改动有量化手段验证。」
二、深挖问答
Q1. 这个项目为什么用 Go 写?RAG 这块 Python 生态不是更主流吗?
面试官想考:技术选型有没有工程判断,还是"我会 Go 所以用 Go";以及你知不知道自己的选择付出了什么代价。
口述回答(背诵这段): 「先给结论:如果重做,我还是会把服务面留在 Go,但我会更早承认代价。选 Go 有三个我认为站得住的理由。一是部署形态——go:embed 把前端产物打进二进制,一个文件就是完整服务,镜像只多一个二进制;而且 Web 和桌面能共用同一个装配包。二是并发模型——一次问答要并发跑向量检索、BM25、多路改写、重排,Go 用 goroutine 加 channel 信号量写起来很直接,cmd/eval 的样本级并发就 20 行。三是交叉编译——CGO_ENABLED=0 之后 Linux、macOS、Windows 五个目标一条命令出包。
代价我也认:RAG 的生态确实在 Python,RAGAS、LangChain 都是 Python 的。所以分块、BM25、RRF 融合、rerank 调用这些我是自己实现的。后来要上业界标准指标的时候,我没有硬啃着用 Go 重写 RAGAS,而是加了一个 Python 评测微服务,Go 用 httputil.ReverseProxy 反向代理过去。我的取舍是:服务面用 Go 保部署和并发,评测面用 Python 吃生态,中间用 HTTP 加内部 token 划清边界。」
讲解与备注:
- 这个回答的关键是「先结论 + 主动认代价」。字节的面试官很反感"因为我熟"这种回答,但你主动说出 Python 生态优势、再给出你为什么仍然选 Go、以及你怎么用架构(Python 微服务)把这个劣势补掉,就变成了加分项。
- 最加分的一句是「服务面用 Go,评测面用 Python,中间用 HTTP 划边界」——它体现了你懂得按边界选语言,而不是全栈一刀切。这个决策在仓库里有明确文档:
docs/35-ragas评测/spec.md:11写明「internal/eval保留 Recall@K 检索指标(需进程内访问 retriever),RAGAS 服务负责生成侧与上下文侧四指标(天然黑盒、可走 HTTP)」。 - 第二个加分点:你没有说"Go 性能比 Python 好"——这个说法在 RAG 场景下基本不成立,因为瓶颈是外部 LLM/Embedding API 的 IO,不是 CPU。真正成立的是"内存占用低 + 部署简单 + 并发写起来不容易错"。
代码依据:
// internal/webui/embed.go:11-12 前端产物打进二进制的关键
//go:embed all:dist
var distFS embed.FS
// internal/app/app.go:1-3 两形态共用装配包的意图写在包注释里
// Package app 提供 BinRag 服务装配:存储、入库 worker、RAG 引擎与 HTTP 路由的统一构建。
// Web 形态(cmd/server)与桌面形态(cmd/desktop)共用本包,保证两条启动路径行为一致。
// Dockerfile:68-69 CGO 关掉才能交叉编译
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-w -s" -o /out/binrag-server ./cmd/serverinternal/webui/embed.go:11-12、internal/app/app.go:1-3、Dockerfile:68-69
追问链:
- 追问:「那你自己实现 BM25 和 RRF,质量能比得上成熟的 Python 库吗?」 → 答:「BM25 和 RRF 都是公式很明确的算法,自己实现风险不大——RRF 就是
sum(1/(k+rank)),我的实现里rrf_k默认 60,和论文一致。真正有风险的是分块和中文分词,这两个我确实踩过坑:分词器一开始对中文是按字切的,后来我加了 CJK unigram 模式,internal/retriever里NewSimpleTokenizer(retriever.WithCJKUnigram())那个选项就是为这个加的。如果重做我会考虑中文直接用 jieba 的 Go 版本或者走 ES。」 - 追问:「如果团队里只有 Python 的人,你还坚持 Go 吗?」 → 答:「那我会先问部署形态。如果是纯内网服务、团队 Python 强,用 FastAPI 也完全可以,Python 的异步生态现在够用。我选 Go 是因为这个项目要出桌面安装包和单文件部署,这两个需求 Go 明显更省事——桌面版 Wails 就是 Go 的,Web 和桌面能共用
internal/app这一层装配。如果只是内部 API 服务,我不会为了 Go 而 Go。」
别踩的雷:
- ❌ 说「Go 性能比 Python 好,所以选 Go」——RAG 场景瓶颈在外部 API IO,这个理由站不住,会被追着问"你测过吗"。
- ❌ 说「Python 生态不行」——恰恰相反,RAGAS 就在 Python,你自己还专门开了个 Python 服务。
- ❌ 说「Go 的 RAG 库很多」——不成立,Go 的 RAG 生态远不如 Python,老老实实说"核心算法我是自己实现的"。
- ✅ 正确说法:选 Go 是为了部署形态(单二进制 + 桌面)+ 并发写法 + 交叉编译,代价是生态要自己补,补法是用 Python 微服务承接评测。
Q2. Web 和桌面两个形态,你怎么做到复用一套代码的?
面试官想考:你的"双形态"是真的架构复用,还是复制粘贴了两份;以及你对 CORS、同源、端口这些 Web 基本功的掌握。
口述回答(背诵这段): 「复用的关键点只有一句:后端装配抽成了 internal/app 这个包,两个入口都调它。 cmd/server 和 cmd/desktop 都执行 app.ParseConfigFlag 解析配置、都调 app.New(cfg) 拿到完整应用、都调 a.Router() 拿 Gin 引擎。差异只有 HTTP 服务怎么起:Web 版是 http.Server{Addr: ":8085"} 加 ListenAndServe();桌面版是 net.Listen("tcp", "127.0.0.1:0") 先绑一个随机端口,再 server.Serve(ln)。
为什么要先 Listen 再 Serve?因为随机端口是内核分配的,你必须先绑定、再用 ln.Addr() 读出端口号,然后把它拼成 http://127.0.0.1:36789/ 传给 Wails 窗口当 URL。不能用 ListenAndServe(),那样你永远不知道端口是多少。
好处是三个:一是前端零改动——Web 和桌面加载的是同一个 URL 路径形态;二是同源,所以没有 CORS 问题——前端所有请求都是相对路径 /api/v1/...,浏览器认为同源,不用配 Access-Control-Allow-Origin;三是不依赖 Wails 的 IPC——我完全没用 Wails 的 binding,桌面版就是一个薄壳,所以前端代码里一个 wails. 调用都没有。」
讲解与备注:
- 这是整个项目工程化上最漂亮的一点,值得讲细。三个层次:装配层复用(internal/app)→ 传输层同源(同一个 Router)→ 壳层最薄(Wails 只提供窗口)。
- 强调「同源」这个词。很多候选人做 Electron/Wails 时会走 IPC,然后前端要写两套调用逻辑(Web 用 fetch,桌面用 IPC),这是典型的技术债。你这里没有,因为桌面版复用了同一个 HTTP 服务。这一点说出来很加分。
application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}这个细节也可以提一句,说明你处理过桌面应用的收尾逻辑:关最后一个窗口就退出,退出时OnShutdown里先server.Shutdown(10 秒超时)再a.Close()。
代码依据:
// cmd/desktop/main.go:36-50 随机端口 + 先 Listen 再 Serve
// 内嵌 HTTP 服务:仅监听本机随机端口
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
slog.Error("监听本地端口失败", "err", err)
os.Exit(1)
}
server := &http.Server{Handler: a.Router()}
go func() {
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("内嵌 HTTP 服务异常退出", "err", err)
os.Exit(1)
}
}()
addr := "http://" + ln.Addr().String() + "/"
// cmd/desktop/main.go:80 窗口加载这个地址
URL: addr,cmd/desktop/main.go:36-50、cmd/desktop/main.go:80、cmd/server/main.go:39-42
追问链:
- 追问:「随机端口的话,用户能不能手动指定端口?」 → 答:「目前不能,桌面版完全忽略配置里的
server.port——我核对过cmd/desktop/main.go全文,它没有读cfg.Server.Port。这是我承认的一个设计缺口:配置项在桌面形态下无效,而且我没有文档化这一点。为什么这么设计呢,是因为桌面场景下端口冲突是常态(用户可能同时开好几个应用),硬编码端口反而更容易起不来,所以我选了"永远不冲突"而不是"可配置"。如果要做,我会加一个"固定端口优先、失败回退随机"的策略。」 - 追问:「桌面版和 Web 版共享一个 PostgreSQL 和 Qdrant,不会有并发写冲突吗?」 → 答:「会有,而且这是设计上的已知约束。桌面版不是纯本地应用,它是同一个后端的第二个前端壳——
app.New里连接 PostgreSQL、跑迁移、连 Qdrant 的代码是完全一样的。所以它必须能访问到那套基础设施。我的定位是"桌面版 = 给单机部署场景少配一个 HTTP 服务",不是"给每个员工装一个本地知识库"。真要做纯本地版,需要把store换成 SQLite、把 Qdrant 换成嵌入式向量库,那是另一个工作量,我在文档里没有声称支持。」
别踩的雷:
- ❌ 说「桌面版是本地应用,数据存在本地」——错,代码里
app.New照样连 PostgreSQL 和 Qdrant(internal/app/app.go:64、:89),没有本地存储分支。 - ❌ 说「用 Wails 的 binding 传数据」——你没用,说这个会被要求现场指代码。
- ❌ 说「桌面版不需要配置文件」——错,桌面版照样走
ParseConfigFlag加config.LoadConfig,配置缺失一样起不来。 - ✅ 正确说法:桌面版是同一个后端 + 一个 WebView 壳,复用的是
internal/app装配和Router(),靠127.0.0.1:0随机端口实现同源。
Q3. 打成单二进制听起来很美,代价是什么?前端改一行是不是要重新编译整个后端?
面试官想考:你有没有为"看起来酷"的方案付过代价的自觉;以及你知不知道什么时候该拆开。
口述回答(背诵这段): 「代价我数三条,都很实在。
第一条就是您说的,前端改一行要重编。 go:embed 是编译期行为,产物在编译时就烤进二进制了,所以前端改动必须走"pnpm build → go build"完整链路。我的缓解手段是分层缓存:Dockerfile 里 go mod download 单独一层、前端 pnpm install 单独一层,只有源码 COPY 会失效;开发阶段前端走 Vite dev server 加 /api 代理到 8085,享受完整热重载,压根不碰 embed。
第二条是无法独立扩缩容。 静态资源和 API 在同一个进程里,静态资源想上 CDN 就得把它从二进制里拆出来——我目前是靠外部反向代理(文档里写的是 Cloudflare)来兜,不是代码层面解决的。
第三条是仓库里有个不那么好看的地方:.gitignore 里 internal/webui/dist 只保留了占位 index.html,其余全忽略。所以新克隆的仓库必须先 build 前端才能编译 Go,CI 里我专门把前端 build 放在 Go build 之前,就是这个原因。
我的判断是:这个代价在"单机或小规模部署、要出桌面安装包"的场景下是划算的;如果这是一个要扛高并发的公网服务,我会把前端拆出去上 CDN,后端只留 API。」
讲解与备注:
- 诚实说出第三条非常加分——说明你真的看过自己的
.gitignore。事实上.gitignore:35-37确实写着internal/webui/dist/*加!internal/webui/dist/index.html,注释也写着「仅保留占位 index.html」;CI 里ci.yml:42的注释就是「前端(产物进 internal/webui/dist,被 go:embed 打包,必须先于 Go 构建)」。 - 「什么时候不该用单二进制」这个反向思考是加分项。面试官喜欢听到边界条件,而不是无脑吹。
代码依据:
# .gitignore:35-37 只保留占位 index.html,保证 go:embed 能编译
# 前端产物目录:整体忽略,仅保留占位 index.html
internal/webui/dist/*
!internal/webui/dist/index.html# .github/workflows/ci.yml:42 这个顺序不是随意排的
# ---- 前端(产物进 internal/webui/dist,被 go:embed 打包,必须先于 Go 构建)----
- name: 安装前端依赖(pnpm workspace)
run: pnpm install --frozen-lockfile.gitignore:35-37、.github/workflows/ci.yml:42-47
追问链:
- 追问:「那为什么不干脆把前端产物一起提交到 Git?」 → 答:「试过这个思路,也留了半个方案——我只提交了占位的
index.html,就是为了让go:embed在干净仓库里能编译过去。但不提交 assets,原因有两个:一是 hash 文件名每次构建都变,会制造大量无意义 diff;二是构建产物进版本库意味着"必须相信提交者的本地构建是干净的",CI 里我反而会失去"从源码重建"的可信度。所以我选的是"仓库里保证能编译,产物由 CI 现烤"。代价就是本地首次构建必须先跑前端。」 - 追问:「前端产物和 Go 二进制版本不一致怎么办?比如只更新了前端。」 → 答:「这正是单二进制的好处——不存在不一致。你不可能只更新前端,因为前端就是二进制的一部分。真要更新就得整体重新构建、重新发版。反过来讲,如果我拆成两个服务,那就要处理"前端发了后端没发"的兼容窗口,那是另一种复杂度。这里我是主动选了一致性换灵活性。」
别踩的雷:
- ❌ 说「前端产物提交进仓库了」——不准确,只提交了占位
index.html(git ls-files internal/webui/dist只返回 1 条)。 - ❌ 说「有 CDN 可以在代码里配置」——代码中未找到任何 CDN 配置,是部署层(反向代理)的事,别说成代码能力。
- ✅ 正确说法:代价是「前端改一行要重编、静态资源无法独立扩缩容、干净仓库需先 build 前端」,缓解手段是「分层缓存 + dev 阶段走 Vite 代理」。
Q4. 桌面版为什么还要连外部的 Qdrant 和 PostgreSQL?打包和签名是怎么做的?
面试官想考:你对"桌面应用"边界的理解,以及你有没有真的走过打包链路(不是只写了代码)。
口述回答(背诵这段): 「先说清楚定位:桌面版不是"本地知识库",它是同一个后端换了个壳。 cmd/desktop 调的是 app.New(cfg),和 Web 版完全一样,里面照样连 PostgreSQL、跑数据库迁移、连 Qdrant、起入库 worker。所以它要求这台机器能访问到那套基础设施——我做它的目的是让单机部署场景少维护一个 HTTP 服务,比如给内网同学发一个 dmg,双击就能用,不用先 docker compose up。
打包我走通了完整的链路:先 pnpm --filter binrag-frontend build 出前端产物,再 go build -o bin/BinRag ./cmd/desktop 编桌面二进制,然后手工组装 .app 结构——Contents/MacOS/BinRag 加 Contents/Info.plist(CFBundleIdentifier 是 com.binrag.app,最低系统 11.0),最后 codesign --force --deep -s - 签名、hdiutil create -format UDZO 出 dmg。Taskfile 里 package 和 package:dmg 两个任务就是干这个的,CI 的 release.yml 在 macos-latest 上重跑同一套。
这里我要主动说一个不足:我用的是 adhoc 签名,也就是 -s -,没有开发者证书、也没有做 notarization 公证。所以用户从网上下下来会被 Gatekeeper 拦,得手动"仍要打开"。这是我知道的、但当前没解决的——真要发布给外部用户,必须买 Apple Developer 账号走公证流程。
还有 CGO:桌面版必须开 CGO,因为 Wails 依赖系统 WebView(macOS 是 WebKit,Linux 是 GTK4 加 WebKitGTK),这和 Web 版特意关掉 CGO 交叉编译是完全相反的策略。」
讲解与备注:
- 主动承认 adhoc 签名是高风险高回报的一句话。风险:暴露你没做完。回报:说明你真走过打包,而且知道生产发布还差什么。这句话建议保留。
- CGO 那个对比是亮点:同一个仓库,Web 版
CGO_ENABLED=0为了交叉编译,桌面版必须 CGO 因为要链接系统 WebView——这解释了为什么ci.yml:55-62要装libgtk-4-dev libwebkitgtk-6.0-dev这些系统库,也是为什么release.yml的桌面 job 只能在macos-latest/windows-latest上本机构建,不能像 Web 版那样在 Ubuntu 上矩阵交叉编译。 - Windows 那边还装了 MinGW(
release.yml:166-167),因为 webview 的 CGO 依赖需要它。
代码依据:
// cmd/desktop/main.go:30 桌面版和 Web 版调的是同一个装配函数
a, err := app.New(cfg)
if err != nil {
slog.Error("应用装配失败", "err", err)
os.Exit(1)
}
// cmd/desktop/main.go:53-58 窗口与 macOS 生命周期
wailsApp := application.New(application.Options{
Name: "BinRag",
Description: "BinRag 企业级文档知识库问答系统",
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: true,
},# .github/workflows/release.yml:123-128 组装 .app 并 adhoc 签名
- name: 组装 .app 并签名
run: |
mkdir -p "bin/BinRag.app/Contents/MacOS"
cp bin/BinRag "bin/BinRag.app/Contents/MacOS/"
cp build/darwin/Info.plist "bin/BinRag.app/Contents/Info.plist"
codesign --force --deep -s - "bin/BinRag.app"cmd/desktop/main.go:30、cmd/desktop/main.go:53-58、.github/workflows/release.yml:123-128、.github/workflows/release.yml:166-167
追问链:
- 追问:「为什么不做成真正的本地应用?装完就能用,不用连数据库。」 → 答:「技术上可行,但要换掉两块:
store从 PostgreSQL 换成 SQLite,vectorstore从 Qdrant 换成嵌入式向量库。我没做,有三个原因:一是评估闭环会断——评测服务是 Python 的,它要回调 Go 的 HTTP 接口,本地版得把这个链路也重构;二是入库 pipeline 里有 ffmpeg 抽帧这类重依赖,本地环境不可控;三是我的真实场景是内网单机部署,那台机器上本来就有 PostgreSQL 和 Qdrant,多一个桌面壳的价值是"不用配 Web 服务",不是"不用配数据库"。所以我把范围收在"壳"这一层。」 - 追问:「桌面版的配置从哪来?普通用户不会写 yaml。」 → 答:「目前的答案是:从文件来,而且用户得自己准备。
cmd/desktop和cmd/server用的是同一套ParseConfigFlag加config.LoadConfig,优先级是命令行-c、环境变量BINRAG_CONFIG、默认./configs/config.yaml。Release 里我把configs/config.yaml示例和 README 一起打进 dmg 了,用户改完配置放在二进制旁边。这确实不好用——更好的做法是首次启动弹一个引导页填 API Key,然后把配置写到用户目录,这是我承认的体验缺口。另外还有个隐患:配置里的file_storage_dir是相对路径./data/uploads,从 Finder 双击启动时工作目录是/,相对路径会解析到根目录——这个问题我没有专门处理。」
别踩的雷:
- ❌ 说「桌面版是纯本地、离线可用」——完全错,它连的还是外部 PG 和 Qdrant。
- ❌ 说「签名走的是 Apple 证书」——是 adhoc(
-s -),说成正式签名一被追问就崩。 - ❌ 说「桌面版不需要 CGO」——反了,桌面版必需 CGO。
- ✅ 正确说法:桌面版 = 同后端 + WebView 壳;打包链是「前端 build → go build → 组装 .app → adhoc codesign → hdiutil」,发布前还差公证。
Q5. go:embed 托管单页应用,前进刷新 404 怎么办?API 的 404 和前端路由的 404 怎么区分?
面试官想考:SPA 部署的基本功,以及你有没有考虑过"API 404 被吞成 index.html"这种经典事故。
口述回答(背诵这段): 「这是 internal/webui 这个包解决的事,一共四条规则。
一,GET / 直接返回 index.html。二,GET /assets/* 走 http.FileServer,加 Cache-Control: public, max-age=86400——因为 Vite 产物是带 hash 的,可以放心长缓存。三,其余 GET 请求走 NoRoute 回退 index.html,这就是前进刷新不 404 的原因——比如 /eval/compare 这种前端路由,服务端不认识,但返回 index.html 让前端路由接管。四,/api/ 开头的路径,以及所有非 GET 请求,返回 JSON 404,不参与回退。
第四条是重点:如果不做这个区分,用户请求一个不存在的 API,会拿到 200 加一段 HTML,前端 resp.json() 解析失败,报一个莫名其妙的错——这种 bug 极难查。所以我在 NoRoute 里先判断前缀和方法。
还有个 embed 特有的坑:embed.FS 不支持 c.File(),所以我没法直接把文件交给 gin 的文件接口,必须 fs.ReadFile 读出内容再用 c.Data 写回。我在代码里专门写了注释。
这四条规则我有单测覆盖:internal/webui/router_test.go 里四个用例——根路径返回 HTML、/kb/some-id 这种深链回退成功、/api/v1/not-exist 返回 JSON 404、/assets 下不存在的文件不能回退成 index.html。」
讲解与备注:
- 「
/assets不存在时不能回退 index.html」这条测试用例(router_test.go:67-77)很能体现思考完整度:因为/assets/xxx.js被浏览器当 JS 解析,如果回退成 HTML,会得到Uncaught SyntaxError: Unexpected token '<',这是前端最经典的坑之一。 - embed 不支持
c.File这个细节,是真正动手写过才会知道的,说出来很值钱。仓库里internal/webui/router.go:39的注释就写着「embed 文件系统不可用 c.File,需读内容后写回」。 - 可以补一句优化方向:「
index.html我目前是每次请求都fs.ReadFile。embed 的读是内存读,不慢,但如果要抠,可以在包初始化时读一次缓存成[]byte。」
代码依据:
// internal/webui/router.go:24-35
r.GET("/", serveIndex(dist))
r.GET("/assets/*filepath", func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=86400")
fileServer.ServeHTTP(c.Writer, c.Request)
})
r.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") || c.Request.Method != http.MethodGet {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "接口不存在"})
return
}
serveIndex(dist)(c)
})
// internal/webui/router.go:39 embed FS 不支持 c.File
// serveIndex 返回 index.html 内容(embed 文件系统不可用 c.File,需读内容后写回)internal/webui/router.go:24-35、internal/webui/router.go:39-48、internal/webui/router_test.go:22,37,52,67
追问链:
- 追问:「为什么
/assets只缓存一天?带 hash 的文件不是可以缓存一年吗?」 → 答:「您说得对,这是我保守了。Vite 的产物名带内容 hash,理论上可以设max-age=31536000, immutable。我设 86400 是防一种情况——如果构建配置变了、hash 算法变了、或者有人手工覆盖了 assets 目录,一天的兜底能保证一天内自愈。代价是所有用户每天至少回源一次。真要抠性能我会改成一年加 immutable,再加一个"如果 index.html 变了就清 assets"的部署钩子。」 - 追问:「
index.html本身你设缓存了吗?」 → 答:「没有,这是故意的。serveIndex里我只c.Data写回,没设Cache-Control,等于走浏览器默认的启发式缓存。这是对的——index.html是唯一不带 hash 的文件,它是资源清单的入口,必须让浏览器"尽快拿到最新版",否则用户会一直加载到旧 hash 的 JS,报了 404 还不刷新。真正的做法是给index.html显式加Cache-Control: no-cache,让它每次都去问一下服务端,这是我该补的。」
别踩的雷:
- ❌ 说「所有 404 都回退 index.html」——错,
/api/前缀和非 GET 是 JSON 404。 - ❌ 说「用
c.File返回 index.html」——编译不过或行为不对,embed FS 不支持。 - ❌ 说「assets 缓存一年」——代码里是
max-age=86400,说错会被抓。 - ✅ 正确说法:四条规则 + 为什么必须区分 API 404 + embed 不能用
c.File。
Q6. Docker 多阶段构建,每个阶段分别做了什么?镜像体积怎么优化的?
面试官想考:你是"会写 Dockerfile"还是"理解每一层为什么存在"。
口述回答(背诵这段): 「我的 Dockerfile 是三阶段,每一阶段的产出都是下一阶段的输入。
阶段一 frontend,基础镜像 node:22。 注意我用的是 glibc 版不是 alpine——这是个踩过的坑:vite 8 依赖 Rust 的 rolldown 和 napi-rs 原生绑定,@napi-rs/lzma 只有 -gnu 变体,在 musl 的 alpine 下加载直接失败,pnpm build 退出码 1。这个阶段先只 COPY package.json、pnpm-lock.yaml、pnpm-workspace.yaml 和两个子包的 package.json,跑 pnpm install --frozen-lockfile,再 COPY 源码跑 pnpm --filter binrag-frontend build。这样分层,源码改动不会触发依赖重装。
阶段二 backend,golang:1.26-alpine 加 Alpine。 同样分层:先 go.mod go.sum 加 go mod download,再 COPY 全部源码,然后用 COPY --from=frontend 把前端产物盖到 ./internal/webui/dist,最后 CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-w -s"。-trimpath 去掉构建机器路径,-w -s 去掉符号表和 DWARF 调试信息。
阶段三 runtime,alpine:3.20。 只装四样东西:ca-certificates(调外部 HTTPS)、tzdata(时区)、ffmpeg(视频抽帧),加一个固定 uid 1000 的用户。然后只 COPY 一个二进制进去。镜像里不含任何配置文件——.dockerignore 把 configs、deploy、data 全排除了,配置靠 volume 挂载,没挂就启动失败,故意的 fail-fast。」
讲解与备注:
- 体积优化的手段要能列成清单:多阶段(丢掉 Node 和 Go 工具链)+ 静态编译(CGO 关掉,不依赖 libc)+ strip(
-w -s)+ 最小基础镜像(alpine 而非 debian)+ 只 COPY 二进制(不带源码)+.dockerignore排除构建上下文。 - 不要报具体镜像大小——仓库里没有任何镜像体积记录,说了就是编。如果面试官追问,诚实说「我没测过具体体积,如果要说数字我需要现场
docker images看一眼;我知道的是它只有 alpine 加一个静态二进制,没有 Node 运行时和 nginx」。 - glibc vs musl 这个坑非常加分,因为它是一个"书本上不会写、只有构建失败过才知道"的细节。而且能顺带解释"为什么运行时用 alpine 但构建用 debian 系"——构建期的原生模块和运行期的静态二进制,约束完全不同。
ffmpeg放进 runtime 阶段是个额外加分点:说明你的服务不止处理文本,视频要抽帧,这是真实的运行时依赖,不是"我随手 apk add"。
代码依据:
# Dockerfile:29 构建阶段用 glibc 版 node
FROM node:22 AS frontend
...
# Dockerfile:52-69 编译阶段
FROM golang:1.26-alpine AS backend
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /app/internal/webui/dist ./internal/webui/dist
ARG TARGETARCH=amd64
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-w -s" -o /out/binrag-server ./cmd/server
# Dockerfile:72-90 运行阶段:只装运行时依赖 + 只拷二进制 + 非 root
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata ffmpeg \
&& addgroup -S -g 1000 binrag \
&& adduser -S -u 1000 -G binrag binrag
COPY --from=backend /out/binrag-server ./binrag-server
USER binragDockerfile:29、Dockerfile:52-69、Dockerfile:72-90、Dockerfile:25-28(glibc 原因注释)
追问链:
- 追问:「
Dockerfile和Dockerfile.deploy有什么区别?」 → 答:「技术上几乎没区别——两个文件除了头部注释和用法说明,逐行是一样的,都是三阶段、同样的编译参数、同样的 alpine 运行时。区别只在用途:Dockerfile是docker build和 compose 的默认入口;Dockerfile.deploy是给 Dokploy 那种"平台让你指定一个 Dockerfile"的场景用的,头注释里写了docker build -f Dockerfile.deploy的完整命令。我承认这是重复代码,更好的做法是抽一份公共的、或者用 build arg 区分。之所以现在这样,是因为两个平台的构建上下文约定不同,我当时选择了复制保证两边都能跑通。」 - 追问:「为什么编译用 alpine、运行也用 alpine,但前端构建不用 alpine?」 → 答:「因为阶段的约束不同。前后端构建阶段需要跑 Node 原生模块(vite 的 rolldown 就是 Rust 编的 napi 模块),这些模块是按 glibc 预编译发布的,musl 下加载不了——这是构建工具的约束。而 Go 编译阶段我用
CGO_ENABLED=0出的是静态二进制,不依赖任何 libc,所以 alpine 完全没问题。运行阶段更不用说,静态二进制在 alpine 上零依赖。所以是"构建要 glibc、产物要越小越好"两件事各取所需。」 - 追问:「健康检查是怎么做的?」 → 答:「这个我做得不好,得说实话。 compose 里给
binrag-server配的探针是wget http://127.0.0.1:8085/swagger/index.html,它能验证"HTTP 服务活着",但不验证 PostgreSQL 和 Qdrant 通不通——因为我在 Go 侧没有实现/healthz端点,只有 Swagger 页可打。更麻烦的是评测服务ragas-eval的depends_on用的是condition: service_healthy,依赖的就是这个探针,所以会出现"Go 起来了但依赖挂了、评测服务照样启动然后报 503"的情况。正确做法是加一个/healthz真的去 ping 一下 PG 和 Qdrant——Python 那边我就是这么做的,/healthz会SELECT 1,db 挂了返 503。」
别踩的雷:
- ❌ 报具体镜像体积数字("大概 20MB")——仓库中没有记录,别编。可以说"没有 Node 运行时和 nginx,只有一个静态二进制"。
- ❌ 说「用了 distroless / scratch」——你用的是
alpine:3.20,而且必须用 alpine 因为有 ffmpeg。 - ❌ 说「健康检查会验证数据库」——不会,只打了 Swagger 页。
- ✅ 正确说法:三阶段各做什么 + 六条体积优化手段 + 诚实承认缺
/healthz。
Q7. compose 里各服务是怎么依赖的?数据卷怎么设计的?为什么向量库和数据库不塞进一个容器?
面试官想考:你对容器编排、数据持久化、服务解耦的理解。
口述回答(背诵这段): 「docker-compose.yml 里是四个服务,项目名 binrag。依赖链是:postgres 和 qdrant 没有依赖;binrag-server 依赖 postgres 的 service_healthy 状态、qdrant 的 service_started;ragas-eval 依赖 binrag-server 的健康。这个链是有意的——评测服务要回调 Go 的问答接口,Go 不在它就干不了活,所以必须等 Go 就绪。
数据卷四个命名卷:pg_data、qdrant_data、data_uploads(上传文件)、eval_data(评测的 SQLite)。全部是命名卷而不是绑定挂载,好处是跨平台不用管宿主机权限。唯一例外是配置文件——它是只读的单文件绑定挂载:./deploy/configs/config.docker.yaml:/app/configs/config.yaml:ro。只读是因为容器不该改配置,单文件是因为我要"没挂载就启动失败"的 fail-fast 效果。
为什么不全塞一个容器——三个理由。一,有状态服务不该无状态部署:PostgreSQL 和 Qdrant 都有自己的备份、恢复、版本升级节奏,混在一个容器里意味着升级 PostgreSQL 会连带重启向量库,而且迁移脚本会互相干扰。二,独立扩容:检索压力大可以单独给 Qdrant 加副本,混在一起就只能整体复制。三,镜像可以复用官方镜像,PG 和 Qdrant 都有官方维护的镜像和安全更新,我自己塞进镜像意味着我要为它们的 CVE 负责。
另外 ragas-eval 不发布宿主端口,只在 compose 内网可达,所有流量统一走 Go 的反向代理——这是一个安全设计:Python 服务的鉴权只有内部 token,不该直接暴露给公网。」
讲解与备注:
- 「有状态服务生命周期不同 + 独立扩容 + 复用官方镜像」这三条是标准答案,但你说的时候要具体到"升级 PG 会连带重启向量库"这种人话,而不是念"关注点分离"。
- 「ragas-eval 不发布端口」是安全加分点,配合内部 token 构成纵深防御。代码里
internal/api/proxy_eval.go:45那个EvalProxy和 Python 侧的InternalTokenMiddleware是配套的。 - 可以补一句 compose 文件的治理现状,显得你清楚技术债:「仓库里有四个 compose 文件——
docker-compose.yml(一键构建全栈)、.prod.yml(拉 ghcr 镜像免编译)、.dev.yml(只起 pg 和 qdrant 给宿主机开发用)、.local.yml(个人本地)。生产那两个都没包含 ragas-eval,这是我知道的缺口;而且.local.yml漏写了name:项目名,卷会落到默认项目名下,和另外三个不一致。」
代码依据:
# docker-compose.yml:52-75 binrag-server 的依赖与卷
binrag-server:
build:
context: .
dockerfile: Dockerfile
image: ghcr.io/bin-hy/documentsrag:latest
ports:
- "8085:8085"
volumes:
- ./deploy/configs/config.docker.yaml:/app/configs/config.yaml:ro
- data_uploads:/app/data
depends_on:
postgres:
condition: service_healthy
qdrant:
condition: service_started# docker-compose.yml:85-105 ragas-eval:不发布端口 + 依赖 Go 就绪
ragas-eval:
build:
context: ./services/ragas-eval
environment:
- EVAL_BINRAG_BASE_URL=http://binrag-server:8085 # 回调 Go 的内网地址
volumes:
- eval_data:/data
depends_on:
binrag-server:
condition: service_healthydocker-compose.yml:52-75、docker-compose.yml:85-105、docker-compose.yml:114-118、docker-compose.yml:77-78
追问链:
- 追问:「
data_uploads用命名卷,用户怎么看到上传的文件?」 → 答:「默认看不到,这是命名卷的代价。compose 注释里我写了替代方案:改成./data:/app/data并保证目录属主是 1000:1000——因为容器里我用的是固定 uid 1000 的binrag用户,宿主机目录属主不对就会写不进去。之所以默认给命名卷,是因为绑定挂载在不同平台上权限行为不一致(尤其 macOS 的 Docker Desktop 有文件共享层,写入性能也差),命名卷是"开箱即用"的选择。要拿文件出来可以docker cp。」 - 追问:「配置只读挂载,那运行时的配置热重载(PUT /config)怎么工作?」 → 答:「这是个真实的冲突,我得说清楚。 我的配置热重载是靠
ConfigManager.Update原子写回配置文件实现的,代码是"临时文件加 rename"。但容器里配置文件是:ro只读挂载,所以写会失败。也就是说:Web 界面上改的可修改配置(LLM 温度、top_k 这些)在容器部署下热重载这条路径是走不通的,只能改宿主机文件后重启容器。这是我承认的一个一致性缺口——正确做法是给容器一个可写的配置副本(比如挂载目录而不是单文件),或者热重载只改内存快照不回写文件。」 - 追问:「
server_healthy和service_started有什么区别,为什么一个用一个?」 → 答:「service_healthy会等 healthcheck 通过,service_started只等容器起来。我对 postgres 用了healthy,因为它有pg_isready探针,而且Go 启动时就要连 PG 并跑迁移,PG 没就绪会直接启动失败。qdrant 我只用了started,因为它没有配 healthcheck——这是我的疏漏,Qdrant 也支持探活,加上会更严谨。之所以现在能跑通,是因为 Go 里EnsureCollection有重试容错,但这是靠运气。」
别踩的雷:
- ❌ 说「配置热重载在容器里也能用」——只读挂载写不进去,会被抓。
- ❌ 说「qdrant 有健康检查」——compose 里没有,别编。
- ❌ 说「ragas-eval 发布在 8090 端口」——不发布,只在 compose 内网。
- ✅ 正确说法:依赖链是有语义的(评测要回调 Go)+ 四个命名卷 + 三个"为什么分容器"的理由 + 主动说缺 qdrant 探针。
Q8. CI/CD 是怎么设计的?跑测试吗?
面试官想考:你是不是把 CI 当成"能跑就行"的摆设,还是真的理解质量门;以及诚实度——这种问题一问细节就露馅。
口述回答(背诵这段): 「三条工作流。ci.yml 在 push main 和 PR 时跑一个 build-and-test job,顺序是:gofmt 检查(gofmt -l . 非空就 fail 并打印文件清单)→ 装 pnpm 依赖 → 前端 vue-tsc 类型检查加 Vite 构建 → 前端 vitest → 装 Wails 需要的 Linux 系统库(libgtk-4-dev、libwebkitgtk-6.0-dev 那几个)→ go build ./... → go vet ./... → go test ./... → 最后对 store、task、api、eval 四个包跑 -race。
docker-publish.yml 在 push main 或打 v* tag 时多架构构建镜像推到 ghcr,标签策略是分支名、sha-短sha、semver 三级(1.2.0 / 1.2 / latest),带 GHA 层缓存。
release.yml 在打 v* tag 时出四个 job:Web 版在 Ubuntu 上矩阵交叉编译五个目标(linux amd64/arm64、darwin amd64/arm64、windows amd64),每个包是二进制加示例配置加 README 加启动脚本;macOS 桌面版在 macos-latest 上本机编、组装 .app、出 .dmg;Windows 桌面版在 windows-latest 上装 MinGW 编 exe、打 zip;最后一个 job needs 前三个,下载全部 artifact 发 GitHub Release。
测试的话,我要诚实分三块说:Go 我跑了 go test ./... 加选择性 -race;前端我跑了 vitest;但 Python 评测服务我一行 CI 都没有——没有 pytest、没有 ruff、也没有构建它的镜像。86 个 pytest 用例和 ruff 检查只在本地跑过。这是明确的缺口,我知道该怎么补:加一个 Python job,uv sync --frozen 加 uv run pytest 加 uv run ruff check。」
讲解与备注:
- 这个问题的核心考点不是你会不会写 YAML,而是你敢不敢承认 Python 没进 CI。主动说出来,然后给出补法,比含糊其辞好一百倍。事实上
grep -rn "services/ragas-eval" .github/返回空,.github/workflows/*.yml里ragas出现 0 次。 - 还有一个可以说出来的缺口:
docker-publish.yml没有needs:依赖ci,也就是说测试失败了镜像照样会推。这是流水线设计上的缺陷,正确做法是让发布依赖测试 job 成功。 - 「为什么 CI 里要
apt-get install libgtk-4-dev」这个问法如果被追问,答案是:go test ./...会覆盖到cmd/desktop,而它 import 了 Wails,Wails 在 Linux 上要 pkg-config 校验到 GTK4/WebKitGTK 才能通过编译,否则go build ./...直接失败(ci.yml:52-54的注释写的就是这个)。 - 测试数量(这是真实统计,可以报):Go 测试文件 63 个、测试函数 405 个;前端 vitest 37 个用例;Python 86 个测试函数。
代码依据:
# .github/workflows/ci.yml:52-75 为什么装 GTK、以及测试与 race
# cmd/desktop 引入 Wails v3(CGO),其内部包在 Linux 上需要 GTK4 / WebKitGTK 6.0
# 等系统库(pkg-config 校验),必须先安装才能通过 go build/vet/test ./...
- name: 安装 Wails Linux 系统依赖
run: |
sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev libglib2.0-dev libsoup-3.0-dev
- name: Go 编译
run: go build ./...
- name: go vet 静态检查
run: go vet ./...
- name: Go 单元测试
run: go test ./...
- name: 关键包数据竞争检测
run: go test -race ./internal/store/... ./internal/task/... ./internal/api/... ./internal/eval/...# .github/workflows/release.yml:22-38 交叉编译矩阵
web-cross-build:
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64.github/workflows/ci.yml:52-75、.github/workflows/release.yml:22-38、.github/workflows/docker-publish.yml:58-63
追问链:
- 追问:「为什么
-race只跑了四个包?」 → 答:「因为我偷懒了。 当时的想法是 store 和 task 有并发写库、api 是请求入口、eval 是样本级并发,这四个风险最高。但这是错的——internal/rag有 85 个测试,里面多路检索和 Multi-Query 都是并发跑的,恰恰是最该跑 race 的包。而且-race会让测试慢好几倍,我就选择性跑了,这是用覆盖率换 CI 时间的错误取舍。正确做法是全量-race,或者至少在internal/rag和internal/retriever上也加上。」 - 追问:「
docker-publish为什么不用等ci通过?」 → 答:「因为我把它们写成了两条独立的 workflow,各自有自己的触发条件。这是缺陷:push main 的时候两条会并行触发,镜像可能在测试挂的情况下推上去。正确做法一是给 job 加needs(跨 workflow 不行,要合成一条 workflow 用needs: build-and-test),二是用workflow_run触发。我当时没这么写,属于对 GitHub Actions 的依赖编排理解不够。」 - 追问:「前端有没有 lint?」 → 答:「没有。
frontend/package.json里只有dev、build、preview、test四个脚本,没配 eslint。但我在build里串了vue-tsc --noEmit,所以类型错误在 CI 里是拦得住的,只是代码风格没有强制。补的话我会加 eslint 加 prettier 加一个 CI step。Python 那边我配了 ruff(select了 E/W/F/I/UP/B/ASYNC 六组规则),但同样没进 CI。」
别踩的雷:
- ❌ 说「CI 全绿,所有模块都有测试」——Python 完全没有 CI,一说就被查。
- ❌ 说「race 全包都跑」——只跑了四个包。
- ❌ 说「镜像发布前会等测试」——
docker-publish.yml没有needs。 - ❌ 报「测试覆盖率 X%」——仓库中未找到覆盖率统计,没有
-cover步骤,别编。 - ✅ 正确说法:三条 workflow 的 job 结构 + 测试分三块说 + 主动认 Python 零 CI 和 race 覆盖不全。
Q9. 你们的配置系统是怎么设计的?改了配置要重启吗?
面试官想考:配置管理的完整度——优先级、覆盖机制、热重载的一致性保证。
口述回答(背诵这段): 「配置优先级是三层:命令行 -c / --config 最高,然后是环境变量 BINRAG_CONFIG,最后默认 ./configs/config.yaml。这三个入口在 internal/config 和 internal/app 里是统一实现的,ParseConfigFlag 用同一个变量绑了两个别名,解析失败就静默返回空串走下一级。
有一个我觉得挺实用的机制:加载主配置之后,会自动尝试读 <主文件名>.local.yaml 做二次覆盖。比如主配置是 configs/config.yaml,它就去找 configs/config.local.yaml,如果存在就再 yaml.Unmarshal 一次——yaml.v3 对已填充结构体的行为是"出现的字段覆盖、没出现的保留"。所以本地开发的密钥、个人 LLM 地址写在 .local.yaml 里就行,不进版本库也不会污染主配置。.gitignore 里我忽略了 *.local.*,但放行 *.local.yaml.example 让模板能提交。
热重载这部分是真做了的。 ConfigManager 的核心是 atomic.Pointer[Config],每个请求 Get() 拿一份不可变快照贯穿整个 pipeline。更新走 Update(),四步:先 ValidateConfig 校验(LLM 温度必须在 0 到 2、retriever top_k 必须在 1 到 50、向量权重加 BM25 权重必须等于 1),再试构建新组件——如果 LLM 地址填错了、组件构建失败,直接返回错误保持旧配置;然后原子写文件(临时文件加 rename,避免写一半);最后原子替换指针。整个 Update 用互斥锁串行化。
所以结论是:LLM、Embedding、Retriever、Reranker 这些改完立刻对新请求生效,不用重启;但启动级组件改不了——Qdrant 连接、BM25 索引、PostgreSQL 连接池、监听端口、worker 数量,这些在 app.New 里构建之后就固定了,改了必须重启。改配置的接口需要 bootstrap key 权限,普通 API Key 会拿到 403。」
讲解与备注:
- 「请求级快照」这个词要用上。它解决的是更新期间的一致性:正在执行的请求不受影响,新请求看到新配置。这是比"读全局变量"高级得多的做法。
- 「先试构建再替换」是回滚语义:
buildRuntime失败就不Store。这个点在internal/app/rebuild.go:61-73和internal/config/manager.go:56-61两处都有。 - 原子写用「临时文件加 rename」——这是文件系统层面的原子性保证,
sync之后再 rename(manager.go:96-103)。这个细节说出来显得你真懂。 - 「哪些能热改、哪些必须重启」一定要分清楚,这是面试官最可能挖的点。
代码依据:
// internal/config/manager.go:42-73 四步原子更新
// Update 原子更新配置:
// 1. 校验 newCfg;2. rebuild 试构建新运行时组件(失败不替换);3. 写配置文件(原子写);4. 原子替换。
func (m *ConfigManager) Update(newCfg *Config, rebuild func(*Config) error) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := ValidateConfig(newCfg); err != nil {
return err
}
if rebuild != nil {
if err := rebuild(newCfg); err != nil {
return fmt.Errorf("新配置组件构建失败,已回滚: %w", err)
}
}
if m.path != "" {
if err := atomicWriteYAML(m.path, newCfg); err != nil {
return fmt.Errorf("写入配置文件失败: %w", err)
}
}
old := m.cur.Load()
m.prev = old
m.cur.Store(newCfg)
return nil
}// internal/config/config.go:347-358 本地覆盖文件自动合并
// 自动合并本地覆盖文件(<主文件名>.local.yaml)
if lp := localOverridePath(path); lp != "" {
localData, err := os.ReadFile(lp)
...
} else {
slog.Info("已合并本地配置覆盖", "local", lp, "main", path)
}
}internal/config/manager.go:15-23、internal/config/manager.go:42-73、internal/config/manager.go:78-104、internal/config/config.go:347-378、internal/api/handler_config.go:192-195
追问链:
- 追问:「热重载的时候,正在跑的请求怎么办?」 → 答:「不受影响。因为快照是请求级的——请求进来时
cfgMgr.Get()拿到的那个指针,会贯穿这个请求的整个 pipeline。Update只是把指针换成新的,旧指针指向的那个 Config 对象是不可变的,不会被就地修改。所以正在执行的请求继续用旧快照跑完,新请求用新快照。这就是我用atomic.Pointer而不是sync.RWMutex包一个可变结构体的原因——避免任何"读到一半被改"的可能。」 - 追问:「那如果新配置的 LLM 地址是通的,但模型名是错的呢?」 → 答:「这种情况下试构建会通过、但请求会失败。因为
buildRuntime只做客户端构造,不发真实请求——它不会去调一次 LLM 验证模型名。所以我的热重载能挡住"地址非法、参数越界、策略枚举写错"这类静态错误,挡不住"配置语法合法但运行时不可用"这类动态错误。要补的话可以在Update里加一个可选的连通性探针,或者等第一次请求失败时自动回滚到prev——我结构体里已经留了prev字段(上一份有效快照)就是为了这个,但回滚逻辑我只实现了"构建失败不替换",没实现"运行时失败自动回滚"。」 - 追问:「配置文件写回的时候服务崩了怎么办?」 → 答:「不会写坏文件。我是先写临时文件、
Sync落盘、再os.Rename。rename 在同一文件系统上是原子操作,所以要么是完整的旧文件、要么是完整的新文件,不存在写一半的中间态。defer os.Remove(tmpName)保证临时文件被清理。这个方案在 Windows 上有个已知限制——os.Rename覆盖已存在文件在旧版 Windows 上不是原子的——但我的部署目标是 Linux 和 macOS,可以接受。」
别踩的雷:
- ❌ 说「所有配置都能热重载」——端口、worker 数、Qdrant 连接这些是启动级,必须重启。
- ❌ 说「热重载会验证模型可用性」——不会,只构建客户端不发请求。
- ❌ 说「运行时失败会自动回滚到旧配置」——
prev字段存在但没有使用,只实现了构建失败不替换。 - ✅ 正确说法:三层优先级 +
.local.yaml自动合并 + 请求级原子快照 + 四步更新 + 明确区分"能热改"和"要重启"。
Q10. 你说做了评估,Recall@K 具体怎么算的?为什么不用 MRR 或 NDCG?
面试官想考:你是不是真的理解自己实现的指标,还是抄了一个名词。字节非常看重"你怎么知道效果变好了"。
口述回答(背诵这段): 「Recall@K 我的实现分两层。
单样本这一层:先取所有 K 值里的最大值 maxK,只发一次检索请求,TopK=maxK,拿到候选 ID 列表;然后对每个 K,取前 K 个做集合,只要 expected_ids 里有任意一个出现在这个集合里,这个 K 就记命中。这里是二值判定,不是分数。
汇总这一层:Recall@K = 命中样本数 / 有效样本数。关键是分母——我剔除了两类样本:检索报错的样本,以及 expected_ids 为空的样本。因为这两种样本算进去只会稀释指标,让数字变得"好看"或者"难看"但没有信息量。
为什么不用 MRR 或 NDCG——这是个好问题,我承认是取舍,不是不能做。三个原因:一,我的标注粒度是"片段 ID 集合",不是排序相关性等级。MRR 只关心第一个命中的倒数排名,NDCG 需要每条结果一个相关性分数(比如 0/1/2/3),我的数据集里没有这个,只有"期望片段集合"。用这个标注去算 NDCG 只能是 0/1 二值版本,退化成和 Recall 差不多的信息量。二,我的目标是"调优时能不能快速看出改动方向对不对",不是发论文。Recall@K 对分块大小、top_k、融合权重这些改动最敏感,一眼能看出趋势。三,成本——expected_ids 已经要人工标注了,再标相关性等级,标注成本翻好几倍。
真要做排序质量,我会先补一个"期望片段的排名分布"统计,然后在有排序标注的前提下加 MRR。现在这个阶段,Recall@1/3/5 三条曲线足够支撑我的调优决策。」
讲解与备注:
- 这不是一个"标准答案"题,是"你有没有想过"题。承认取舍 + 说清标注约束 + 给出如果要做的路径,比硬答"Recall 最好"强得多。
- 那个「只发一次检索请求取 maxK」的实现细节很值钱,说明你考虑过性能:如果每个 K 各发一次检索,三次 embedding 调用加三次向量检索,成本是 3 倍。
maxK复用是明显正确的优化。 - 分母剔除错误样本这点一定要说,因为面试官很可能追问"分母是什么"。而且严格说这不是标准 Recall 定义——标准定义分母是全体标注样本。你要主动说明**"我的分母是有效样本"**,并解释理由,否则被指出来就变成"你实现错了"。
- 「多期望片段只算一次命中」也是一个可以主动提的简化:严格讲 Recall 应该是命中数除以期望总数。我的实现是"任一命中即算命中",等于把每个样本的二值化——这对"这个问题能不能被检索到"的判断是对的,但不是严格的多标签 Recall。主动说比被问出来好。
代码依据:
// internal/eval/evaluator.go:125-132 只发一次检索,用 maxK
maxK := 1
for _, k := range kValues {
if k > maxK {
maxK = k
}
}
out, err := rt.Search(ctx, retriever.RetrieveRequest{Query: s.Question, TopK: maxK, Filter: filter})
// internal/eval/evaluator.go:144-160 任一期望片段命中即记命中
// 判定:期望片段任一出现在前 K 内即命中该 K
for _, k := range kValues {
top := res.Retrieved
if len(top) > k {
top = top[:k]
}
topSet := make(map[string]bool, len(top))
for _, id := range top {
topSet[id] = true
}
for _, eid := range s.ExpectedIDs {
if topSet[eid] {
res.Recall[k] = true
break
}
}
}// internal/eval/report.go:49-63 汇总:分母剔除错误样本与无期望片段样本
for _, k := range kValues {
hit, valid := 0, 0
for _, res := range results {
if res.Error != "" || len(res.Sample.ExpectedIDs) == 0 {
continue // 错误样本与无期望片段样本不计入分母
}
valid++
if res.Recall[k] {
hit++
}
}
if valid > 0 {
r.RecallByK[k] = float64(hit) / float64(valid)
}
}internal/eval/evaluator.go:125-160、internal/eval/report.go:38-63、internal/eval/dataset.go:14-25
追问链:
- 追问:「
valid等于 0 的时候怎么办?」 → 答:「这个 K 就不写进报告——if valid > 0才赋值,所以 JSON 里recall_by_k会直接少这个键,文本报告也不打印这一行。为什么这么设计:如果所有样本都没有expected_ids,报一个Recall@5 = 0是严重误导,用户会以为检索挂了;不报才准确反映"这个指标在你的数据集上算不出来"。严格讲更好的做法是显式输出一个"N/A"或者加个 warning,光"静默不报"容易被忽略——这是我该改进的。」 - 追问:「
expected_ids是 chunk ID,但 chunk ID 会变吧?」 → 答:「这是这个评估体系最脆的地方,您问到点子上了。 chunk ID 是在入库时分块生成的,所以只要我改了分块策略或者 chunk_size,所有 ID 全部失效,数据集就得重标。这意味着"调分块参数"这个最需要评估的场景,恰恰是最难评估的。我有两个缓解思路:一是改用稳定的内容指纹当期望标识,比如对 chunk 正文做 hash,这样只要内容还在、ID 变了也能对上;二是在数据集里同时记"期望片段所在的文档 ID 加关键词",做模糊匹配。目前我两个都没实现,这是我的技术债,也是我认为这个评估框架最大的局限。」 - 追问:「Recall@5 到 0.9 但答案还是错的,你怎么排查?」 → 答:「这说明检索没问题、生成侧有问题,我会分三步看。第一步看
cmd/eval输出里的逐样本明细——它会把每个样本的命中情况、回答摘要、准确性和忠实度都打出来,错误样本一眼能定位。第二步看忠实度:如果忠实度是 false,说明 LLM 编了资料里没有的东西,问题在 prompt 或者上下文注入;如果忠实度 true 但准确性低,说明资料本身对了但 LLM 没理解,那是模型能力或 prompt 结构问题。第三步看上下文预算:我的rag.max_context_tokens默认 2048、max_chunks默认 5,如果命中片段排名靠后被截断了,那 Recall@5 好看但实际注入的上下文里没有它——这是一个很隐蔽的坑。」
别踩的雷:
- ❌ 说「分母是全量样本」——是有效样本,剔除了报错和无标注的。
- ❌ 说「实现了标准的多标签 Recall」——任一命中即算命中,不是命中数除以期望总数。
- ❌ 编一个 Recall@K 的实测数值——仓库中未找到任何 Recall@K 实测数字(详见 Q14)。
- ✅ 正确说法:两层算法 + 分母口径 + 三条不用 MRR/NDCG 的理由 + 主动说 chunk ID 会变这个硬伤。
Q11. LLM-as-Judge 你怎么设计的?评分维度是什么?prompt 长什么样?分数怎么归一?
面试官想考:你对"用模型评模型"这件事的严谨度——可复现性、输出稳定性、偏差控制。
口述回答(背诵这段): 「我有两套 judge:Go 侧 cmd/eval 用两个维度,Python 侧 RAGAS 用四个标准指标。
Go 侧两个维度:准确性和忠实度。
准确性是 0 到 10 分,prompt 里给了四档锚点:9-10 是完全正确覆盖所有关键点,6-8 是基本正确有少量遗漏,3-5 是部分正确有明显错误,0-2 是完全错误或答非所问。要求模型只输出 JSON {"score": 整数},我在 judgeAccuracy 里解析完还会校验范围,超过 0 到 10 直接报错,防止模型给个 12 分我还算进均值。平均值是只对非 nil 的评分求均值,我用了 *float64 指针,所以"没评"和"评了 0 分"是能区分开的。
忠实度是二值的,prompt 要求判断回答是否完全基于引用资料,输出 {"faithful": true} 或 false。报告里算的是忠实比例——true 的占比。
三个可复现性的保障:一是温度强制 0,两个 prompt 都走同一个 generateJSON,里面固定加 WithTemperature(0);二是JSON 容错提取——模型常常会多说几句,我取第一个 { 到最后一个 } 之间的内容再解析,避免因为多一句"希望有帮助"就整条失败;三是 judge 模型可以单独配置,不和生成模型绑死,可以用更强的模型当裁判。
Python 侧那四个就是 RAGAS 标准的 faithfulness、answer_relevancy、context_precision、context_recall,这个不是我定义的,是框架定义的,而且我把四套中文 prompt 整段覆写了,因为原始 prompt 是英文的,评中文语料质量不行。
分数归一:Go 侧准确性是 0 到 10 的原始分,我报告里直接显示 平均准确性: X / 10,没有归一化到 0 到 1,因为我觉得 10 分制更符合人的直觉(像打分)。RAGAS 侧四个指标框架本身就输出 0 到 1,我不做二次变换。」
讲解与备注:
- 主动区分两套 judge 很重要,否则面试官会以为你说混了。Go 侧是自定义 prompt 的二值加十分制,Python 侧是 RAGAS 的四指标 0 到 1。两套的口径不能直接比,这一点仓库文档里也明确写了(
docs/35-ragas评测/spec.md:40的 N5:「两套体系口径差异在文档中明示,报告不直接互比」)。 - 用指针区分"没评"和"0 分" 是个很细的设计点,
report.go:20-21就是Accuracy *float64和Faithful *bool。说出来显示你考虑过数据语义。 - 中文 prompt 覆写是 RAGAS 落地的真实难点:RAGAS 的 prompt 是英文 few-shot,评中文会明显偏低。
PROMPT_VERSION现在是zh-v2,说明迭代过一版——而 v2 的变更原因很具体(输出 shape 要和 ragas 0.3.9 的 output_model 对齐),这是真调过的证据。 - 诚实说 Go 侧忠实度只喂了文件名和标题、没喂正文——这是最容易被抓住的弱点,主动说。
代码依据:
// internal/eval/judge.go:14-31 准确性 prompt 与确定性温度
const accuracyPrompt = `你是一个严格的 RAG 问答质量评审员。请根据「标准答案」评判「模型回答」的准确性。
评分标准(0-10):
- 9-10:完全正确,覆盖标准答案所有关键点
- 6-8:基本正确,有少量遗漏或轻微偏差
- 3-5:部分正确,存在明显错误或遗漏关键点
- 0-2:完全错误或答非所问
只输出 JSON:{"score": <0-10 的整数>},不要输出其他内容。`
// zeroTemp 评审使用确定性温度
var zeroTemp float32 = 0.0// internal/eval/judge.go:94-100 JSON 容错提取
// 清理:提取首个 { 到末尾 }(模型可能附带解释文字)
start := strings.Index(out, "{")
end := strings.LastIndex(out, "}")
if start < 0 || end <= start {
return "", fmt.Errorf("评审输出不含 JSON: %q", out)
}
return out[start : end+1], nil// internal/eval/report.go:64-76 只对非 nil 评分求均值
var accSum float64
accCount := 0
for _, res := range results {
if res.Accuracy != nil {
accSum += *res.Accuracy
accCount++
}
}
if accCount > 0 {
avg := accSum / float64(accCount)
r.AvgAccuracy = &avg
}internal/eval/judge.go:14-31、internal/eval/judge.go:81-101、internal/eval/report.go:20-21、internal/eval/report.go:64-90
追问链:
- 追问:「忠实度判断你喂了什么给 judge?」 → 答:「只喂了文件名和标题,没喂正文——我得主动说这个,这是它的硬伤。代码里
JudgeFaithfulness遍历 sources 只取src.Filename和src.Heading拼进 prompt。这样 judge 实际判断的是"回答的结论是否和来源的主题一致",而不是"回答的每个断言是否能在正文里找到依据"。严格的做法必须把 chunk 正文喂进去。为什么现在是这样——因为 Go 侧评估最初设计时rag.Source结构里没有正文,content字段是我后来为了 RAGAS 采集才加的(配合include_contexts=true参数)。所以这里是可以修的:把res.RawSources里的Content也喂进去就行,我数据结构里已经保留了RawSources []rag.Source。」 - 追问:「温度设 0 就一定可复现吗?」 → 答:「不敢保证,只能说是"确定性优先"。 温度 0 让采样退化成 greedy,理论上同一输入同一模型应该给同一输出。但实际有三层不确定性:一是模型服务端版本会变,供应商静默升级模型,同样的 prompt 结果就变了;二是浮点累加顺序,batch 推理和不同硬件上可能有极小的数值差异,偶尔会翻掉一个 token,然后后续全变;三是上下文太长被截断,如果引用资料超了窗口,截断位置不同结果就不同。所以我做的是"降低方差",不是"保证可复现"。RAGAS 那边我还额外记了 config_snapshot——把数据集 hash、judge 模型名、RAGAS 版本、prompt 版本全部存档,这样两次评测结果不一样时我能知道是不是配置变了导致的。」
- 追问:「如果 judge 输出解析失败怎么办?」 → 答:「Go 侧和 Python 侧的策略不一样。Go 侧比较粗暴:
generateJSON提取失败就直接返回 error,这个样本被标记成错误、不计入指标——所以我只能从ErrorCount看出"有一批样本挂了",不知道挂的原因分布。Python 侧做得细:解析失败重试 2 次,还失败就记 NaN 并计入parse_failure_rate,报告里会输出这个比率,超过 5% 就把整份报告标成reliable: false。我在联调时真的踩到过这个问题——修 prompt 之前失败率是 0.92,报告正确地标记了不可靠;修完之后降到 0.0417,才变成可靠。这个机制救过我一次,不然我会拿一份全是 NaN 的报告去下结论。」
别踩的雷:
- ❌ 说「准确性归一化到 0 到 1」——没有,是 0 到 10 原始分。
- ❌ 说「忠实度喂了完整上下文」——只喂了 filename 和 heading。
- ❌ 说「温度 0 所以结果完全可复现」——只能说确定性优先,会被追问浮点和模型版本。
- ❌ 说「Go 侧也有 parse_failure_rate」——没有,那是 Python 侧的机制。
- ✅ 正确说法:两个维度 + 四档锚点 + 温度 0 + JSON 容错 + 指针区分未评/0 分 + 主动认忠实度没喂正文。
Q12. 评估数据集是怎么来的?expected_ids 从哪来?构建评估集最难的地方是什么?
面试官想考:你有没有真的建过评估集——这题答不上来,说明评估是纸上谈兵。
口述回答(背诵这段): 「数据集格式很简单,就是一条样本四个字段:question 必填,answer 标准答案可选,expected_ids 期望命中的片段 ID 数组,kb_id 可选用来限定知识库范围。支持 JSON 整体和 JSONL 逐行两种格式。校验规则很严——question 去空白后不能为空,expected_ids 必须是数组、不能是 null,写 null 我直接报错并在错误信息里带行号。
expected_ids 从哪来,我要坦白说:靠人工标注,而且没有工具。目前的做法是:先把一批文档入库,然后通过检索接口或者数据库去查 chunk ID,人工确认哪些片段真正回答了这个问题,把它们填进数据集。这个流程没有脚本支持,也没有 UI,只能手工做。
难的地方我数三个,越往后越难。
第一,标注成本。 一个中等规模的数据集比如 60 条样本,每条都要找到对应片段并核对,是实打实的人工活。RAGAS 那边的设计文档里我自己都写了"expected_ids 标注成本高",所以 Python 侧允许只标 answer 不标 expected_ids——有 reference 就能跑三个指标,缺了也能跑两个。
第二,chunk ID 不稳定,这是最致命的。 分块是入库时做的,所以只要我调整 chunk_size 或者分块策略,所有 chunk ID 全部重生成,数据集里的 expected_ids 全部失效。这意味着"评估分块策略"这个最需要评估的场景,反而是最难评估的——你要先重标一遍数据集才能比。这个我目前没有解决方案,思路是用内容 hash 替代 ID,但没实现。
第三,标注本身的正确性没人校验。 我标"这个片段回答了这个问题",是我自己的判断。如果标错了,Recall 会偏低,但看起来像"检索效果不好",会把我往错误方向带。理想的低成本做法是做交叉标注或者用 LLM 先标一版再人工复核,但我都没做。
实际做过的规模:RAGAS 的验收里我用的是 7 条样本的数据集,其中 2 条没有 standard answer、1 条故意指向不存在的知识库用来测容错。这是个功能验证级的规模,不是基线级的规模——几十条样本的统计意义很弱,这也是我承认的。」
讲解与备注:
- 这题必须诚实。面试官只要问一句"数据集多少条、谁标的",编造立刻穿帮。而且"7 条样本"这个数字在
docs/35-ragas评测/验收报告.md:3里有明确记录:「7 样本真实数据集(含 2 条无 reference、1 条坏 kb_id)」。 - 主动说出「这批数据集的规模只够做功能验证,不够做基线」是加分项——它体现你知道统计显著性的边界。很多人会说"我做了评估",但你把它定性成"验证框架可用"而不是"得出了结论",这是更成熟的说法。
- 「chunk ID 不稳定导致无法评估分块策略」这个洞察非常值钱,是真正动手过才会发现的问题。面试官如果没想过,会觉得你比他想得深。
- 反向的一个加分点:故意在数据集里放一条指向不存在知识库的样本来测容错(验收记录里那条坏
kb_id),这说明你评估的是"系统的鲁棒性"而不只是"平均分"。可以主动说。
代码依据:
// internal/eval/dataset.go:14-25 数据集模型
// EvalSample 数据集单条样本
type EvalSample struct {
Question string `json:"question"` // 问题
Answer string `json:"answer,omitempty"` // 标准答案(可选,仅问答/全量模式用)
ExpectedIDs []string `json:"expected_ids"` // 期望检索片段 ID(Recall@K 判定依据)
KBID string `json:"kb_id,omitempty"` // 知识库范围(可选,空=不限定)
}// internal/eval/dataset.go:86-93 严格校验(expected_ids 为 nil 直接报错)
for i, s := range d.Samples {
if strings.TrimSpace(s.Question) == "" {
return fmt.Errorf("第 %d 条样本 question 为空", i+1)
}
if s.ExpectedIDs == nil {
return fmt.Errorf("第 %d 条样本 expected_ids 为 nil(应使用空数组)", i+1)
}
}internal/eval/dataset.go:14-25、internal/eval/dataset.go:79-95、docs/35-ragas评测/验收报告.md:3、docs/35-ragas评测/prompt-design.md:513
追问链:
- 追问:「为什么不用 LLM 自动生成评估集?」 → 答:「可以做,而且这是业界常见的做法——从文档里切一段,让 LLM 生成"这段能回答什么问题",再用这段当标准答案。但我现在没做,原因是这套流程有个典型偏差:LLM 生成的问题和它给的答案是同源的,检索系统只要召回那段原文就一定得高分,等于自己给自己出题,指标会虚高。要做我会分两步:LLM 先生成一版候选,然后人工筛掉那些"答案和原文措辞完全一致"的题,保留需要跨段落推理的,这样才有区分度。仓库里的设计文档提过合成数据集生成 prompt 是"草案、不纳入本期功能",所以这是明确规划但未做的。」
- 追问:「
kb_id字段是干什么的?」 → 答:「用来限定检索范围。评估的时候如果样本属于某个知识库,就在检索请求里加上kb_id过滤条件。空的话就是全局检索。这个字段在 RAGAS 侧还有第二个用途——采集的时候传给 Go 的问答接口,保证评测采到的回答和真实用户在这个知识库下的回答是一致的,也就是"评测所见即生成所用"。设计文档里的 D4 决策就是这个。」 - 追问:「两种格式为什么要都支持?」 → 答:「JSON 适合小数据集和人工编辑,一次
json.Unmarshal就完事,还能带name字段。JSONL 适合大数据集和脚本生成,一行一条,流式解析、内存友好、diff 友好——改一条就只动一行,Git 上能看清改了哪条。我对 JSONL 还专门把 scanner 的缓冲调成了 1MB,因为默认 64KB 遇到长文本样本会报token too long。这个坑我踩过,所以代码里显式设了scanner.Buffer(make([]byte, 1024*1024), 1024*1024)。」
别踩的雷:
- ❌ 编一个数据集规模("我们有 500 条标注数据")——没有,验收记录是 7 条。
- ❌ 说「有自动标注工具 / 有标注 UI」——代码中未找到,纯手工。
- ❌ 说「用 LLM 生成了数据集」——没有,只在设计文档里作为草案提过。
- ✅ 正确说法:四字段格式 + 人工标注无工具 + 三个难点(成本 / ID 不稳定 / 标注质量)+ 明确说现在只到"功能验证级"。
Q13. 为什么要单独拉一个 Python 的 RAGAS 服务?Go 怎么调它?限流和队列怎么设计的?
面试官想考:你的服务拆分是不是有理由的;以及你对异步任务、限流、幂等、失败恢复这些后端硬功夫的掌握。这题是全场最能体现深度的一题。
口述回答(背诵这段): 「为什么拆:RAGAS 是 Python 生态的框架,用 Go 重写它的四指标不现实。但我没有把整个系统搬去 Python,而是按边界拆——服务面留 Go,只把"评测执行"这一段拆出去。为什么这一段可以拆?因为它天然是黑盒的:RAGAS 只需要「问题、回答、检索到的上下文、标准答案」四个输入,不需要访问 Go 的进程内对象。而 Recall@K 必须拿到完整候选集,走 HTTP 拿不到,所以它留在 Go。
Go 怎么调:反向代理。/api/v1/eval/* 全部透传给 Python,Go 只做三件事:一是前置鉴权,复用现有的 JWT 和 API Key 中间件;二是注入内部 token,头叫 X-Eval-Internal-Token,Python 侧中间件校验,配置为空时一律 401,不容忍裸奔;三是一个业务逻辑——提交评测任务时解析 body 里的 kb_id 做越权校验,越权返 404。注意 body 读出来之后必须重新包一个 Reader 塞回去,否则透传就没 body 了。响应体我不做任何改写,所以 Python 和 Go 暴露的路径、包装格式完全一致。
队列和限流是三层设计。 第一层任务级:worker 数等于 max_running_tasks,默认 2,超出就排队,队列上限 16,满了返回 429。第二层样本级:采集并发默认 4、上限钳到 8,用 asyncio.Semaphore 控制;评测按 eval_batch_size 默认 8 条一批喂给 RAGAS。第三层LLM 速率:一个令牌桶,judge_rpm 默认 60,容量等于一分钟配额,允许瞬时突发但不超长期速率。
几个我觉得值得说的机制:一是幂等——提交任务支持 Idempotency-Key 头,24 小时内同一个 key 返回同一个任务,但 HTTP 状态码是 200 不是 201;这是防前端双击的,因为一次评测要烧不少 LLM 额度。二是重启恢复——服务重启时把所有 collecting 和 evaluating 状态的任务重置为 pending 重新排队,样本表上有个 UNIQUE(task_id, idx) 约束,所以已完成的样本会跳过,做到断点续跑。我实测过:evaluating 中途 kill 掉进程再重启,任务自动续跑完成,样本没有重复计数。三是取消是协作式的——不是硬断连接,而是置一个 asyncio.Event,worker 在样本边界检查,正在进行的 LLM 调用会跑完。这样不会留下半截的数据。四是单样本失败不阻断任务——采集或评测失败的样本单独记 error,任务整体还是 completed,完成判定是"ok 加 failed 等于 total"。」
讲解与备注:
- 这是含金量最高的一题,因为涉及服务拆分、代理设计、异步任务、限流、幂等、断点续跑六件事。建议讲满,不要抢时间。
- 幂等用 200 而不是 201 这个细节很值钱,
routes_tasks.py:66-74里就是这么实现的,说明你想过 HTTP 语义。 UNIQUE(task_id, idx)是断点续跑的基石——这个说法能体现你说得清"为什么这个约束存在",而不只是"我加了唯一索引"。- 协作式取消 vs 硬断 是一个能体现后端成熟度的对比,值得展开一句"硬断会留下半截数据"。
- 「先试构建再替换」「429 时回滚删除任务骨架」这类细节也能加分:
routes_tasks.py:113-116在队列满时会把刚落库的任务删掉再返 429,避免库里留一堆永远跑不起来的僵尸任务。这个细节一定要说,非常体现工程细致度。 - 别忘了一句边界话术:「这个服务目前是单副本,设计文档里明确写了本期不做多副本水平扩容,是垂直扩容。因为队列是进程内 asyncio.Queue,多副本下队列不共享,要做水平扩容得先把队列换成 Redis 之类的外部组件。」
代码依据:
// internal/api/proxy_eval.go:68-83 纯透传 + 只注入 token,不改写路径与响应
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
// 纯透传:仅改写目标 scheme/host,路径与查询串保持原样;Body 不动
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
req.Header.Set(evalInternalTokenHeader, token)
},
ErrorHandler: func(rw http.ResponseWriter, _ *http.Request, err error) {
slog.Warn("评测服务代理失败", "path", c.Request.URL.Path, "err", err)
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
rw.WriteHeader(CodeBadGateway)
_ = json.NewEncoder(rw).Encode(Response{Code: CodeBadGateway, Message: "评测服务不可用: " + err.Error()})
},
}# services/ragas-eval/src/ragas_eval/api/routes_tasks.py:65-74 幂等:24h 内同 key 返回 200
# 幂等键查重:24h 内同 key 返回首个任务(HTTP 200 而非 201)
if idempotency_key:
existing = await state.repo.find_task_by_idempotency_key(idempotency_key)
if existing is not None:
created = datetime.fromisoformat(existing.created_at)
age = (datetime.now(UTC) - created).total_seconds()
if age < _IDEMPOTENCY_WINDOW_SECONDS:
return ok(task_view(existing), status_code=200)# services/ragas-eval/src/ragas_eval/store/db.py:48-62 UNIQUE(task_id, idx) 是断点续跑的基石
CREATE TABLE IF NOT EXISTS samples (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
idx INTEGER NOT NULL,
...
UNIQUE(task_id, idx) -- 样本级幂等:重启续跑跳过已 ok 样本
);internal/api/proxy_eval.go:45-109、services/ragas-eval/src/ragas_eval/core/queue.py:60-102、services/ragas-eval/src/ragas_eval/store/db.py:61、services/ragas-eval/src/ragas_eval/config.py:60-64
追问链:
- 追问:「队列是进程内的,服务重启队列就丢了,怎么办?」 → 答:「这正是我把任务状态落库的原因。 队列里存的是 task_id 字符串,不是任务实体——任务实体在 SQLite 的
tasks表里。所以队列丢了不影响,重启时start()会做两件事:先把所有 collecting 和 evaluating 状态的任务reset_interrupted_tasks()重置为 pending,再list_resumable_task_ids()把所有可恢复的任务重新塞进队列。而且因为样本表有UNIQUE(task_id, idx),已经采集或评测完的样本会被跳过——所以是样本级的断点续跑,不是任务级重跑。代价是如果服务重启时正好有一个样本在跑,那个样本会被重置为 pending 重跑一次,这一条的 LLM 成本会浪费,但不会产生重复数据。」 - 追问:「限流的令牌桶是自己写的?为什么不用现成库?」 → 答:「自己写的,48 行。为什么不用——Python 的限流库要么是同步的(比如
ratelimit包),要么假定有 Redis 做分布式协调。我这里是单进程内的异步限流,需求很窄:按 RPM 匀速补充、acquire时没令牌就挂起等待、允许不超过容量的突发。用asyncio.Lock保护令牌计数、用time.monotonic()算经过时间补充令牌、算出来还差多少就sleep那么久。注释里我写了语义对齐 Go 侧的rate.NewLimiter。如果将来要分布式,我第一个换掉的就是它,换成 Redis 加 Lua 脚本。」 - 追问:「采集调 Go 的 chat 接口,为什么不批量?」 → 答:「因为 Go 的 chat 接口本来就是单问题的——它的输入是
question加session_id,一次一个问题。要批量得改 Go 的接口契约,而且每个问题的检索和生成是独立的,批量并不能省 LLM 调用。我的优化是在并发上做的:任务内按sample_concurrency分批,每批用asyncio.gather并发发出去,信号量限制在飞请求数。每个样本会生成一次性的session_id,形如eval-{uuid},这是为了避免样本之间通过对话历史互相污染——这个 bug 我在联调时真踩过:Go 侧session_id是必填的,一开始我没传,采集直接报错;后来发现如果复用 session,前一个问题的回答会进入下一个问题的历史,评测结果就不可信了。」
别踩的雷:
- ❌ 说「评测服务和 Go 共用一套鉴权」——是两层:Go 用 JWT/API Key 对用户鉴权,Go 到 Python 之间用内部 token。
- ❌ 说「取消会立刻中断 LLM 调用」——是样本边界的协作式取消,进行中的调用会跑完。
- ❌ 说「队列是 Redis 或者消息队列」——是进程内
asyncio.Queue,这也是不能水平扩容的原因。 - ❌ 说「任务失败要整个重跑」——有样本级断点续跑。
- ✅ 正确说法:按边界拆服务 + 反向代理三件事 + 三层限流 + 幂等 200 + 协作式取消 + 队列进程内所以单副本。
Q14. 那你优化完之后,Recall 或者效果指标提升了多少?有数据吗?
面试官想考:⭐ 这是最关键的一题。 他要验证你的"评估"是真做了还是摆样子。这题唯一正确的策略是诚实。做假数据,追问两句就崩,而且是诚信问题。
口述回答(背诵这段): 「我分三块说,其中两块我必须说清楚没有数据。
**先说有数据的部分:RAGAS 那套四指标,我在一次端到端联调里跑出过一组数。**用的是 7 条样本的真实数据集,所以统计意义很弱,但确实是真跑出来的:faithfulness 是 1.0,answer_relevancy 是 0.78,context_precision 是 0.83,context_recall 是 0.95。同一份记录里,parse_failure_rate 最终是 0.0417,低于 5% 的阈值,报告标记为 reliable;修 prompt 之前这个值一度是 0.92——也就是说十次 judge 调用有九次解析失败,报告正确地把它标成了不可靠,这个机制当时救了我一次,不然我会拿一份全是无效值的报告去下结论。这组数字记录在 docs/35-ragas评测/验收报告.md 里。
第二块,Go 侧的 Recall@K,我要明确说:仓库里没有任何实测数字。评估框架是完整落地的——公式、报告、单测都有,cmd/eval 能跑出 Recall@1/3/5 三个值,但我没有跑过一份有代表性的基线。所以面试时我不会说"Recall@5 提升了 20%"这种话。诚实的说法是:"评估框架已经落地并跑通端到端,但完整基线数据我还没跑完。"
第三块要说一个坑:仓库文档里有个"预期 Recall 提升 10%+"的表述,那是我在优化计划里写的预期值,不是实测值——是计划阶段的假设,我没跑完验证。如果有人拿着这个问,我会明确说是预期不是结果。
为什么没跑完基线,我可以说真实的障碍:一是数据集要人工标 expected_ids,没有工具;二是分块策略一变 chunk ID 全变、数据集就得重标,所以"调分块"这个最该评估的场景恰恰最难评;三是跑一次完整评测要烧不少 LLM 额度,我在开发阶段是省的。这三条都是我认的技术债。」
讲解与备注:
- ⭐ 这一题决定面试官对你整份简历的信任度。 前面所有"我做了评估框架"的说法,都要靠这一题的诚实来兜底。
- 有数据就精确引用、标明样本量、标明来源:
docs/35-ragas评测/验收报告.md:28写着「AC4 四指标报告(验证:终验 faithfulness=1.0 / relevancy=0.78 / precision=0.83 / recall=0.95,逐样本明细齐全)」,:32写着「AC10 失败率上报(验证:终验 parse_failure_rate=0.0417 <5%,reliable=True;修复前 0.92 时 reliable=False 正确拦截)」,:3写明样本量是「7 样本真实数据集(含 2 条无 reference、1 条坏 kb_id)」。 - 主动交代 0.92 到 0.0417 这个修复过程是极强加分项:它把一个"数字不好看"的事实变成了"我的可靠性机制真的起作用了"的证据。面试官会记住这个。
- 主动澄清
docs/15-rag优化计划.md:74的"预期 Recall 提升 10%+" 是这个文档里我最推荐的加分动作之一。因为面试官如果自己去翻仓库,看到这句话再来问你"这是你测出来的吗",你被动解释就很尴尬;你主动指出来,性质完全不同。 - 样本量小这件事必须自己先说。「7 条样本统计意义很弱」这句话自己说出来是自知之明,被面试官指出是硬伤。
- 一个可以加分的收尾:「所以我现在对这个评估体系的定位是——**它是一把已经校准好的尺子,但还没量出有意义的长度。**框架、公式、容错、报告、对比视图都通了,缺的是规模化的标注数据。这是我认为下一步最该投时间的地方,比加检索策略优先级更高。」
代码依据:
<!-- docs/35-ragas评测/验收报告.md:28 四指标实测值(7 样本,来源见 :3) -->
- [x] AC4 四指标报告(验证:终验 faithfulness=1.0 / relevancy=0.78 / precision=0.83 / recall=0.95,逐样本明细齐全)
<!-- docs/35-ragas评测/验收报告.md:32 可靠性机制真的拦住了坏报告 -->
- [x] AC10 失败率上报(验证:终验 parse_failure_rate=0.0417 <5%,reliable=True;修复前 0.92 时 reliable=False 正确拦截)
<!-- docs/35-ragas评测/验收报告.md:3 样本量必须一起说 -->
> 验收日期:2026-09-10。验收方式:本地全链路联调(Go :8085 + ragas-eval :8090 + ollama qwen2.5:7b/nomic-embed-text + dev compose pg/qdrant),7 样本真实数据集(含 2 条无 reference、1 条坏 kb_id)。<!-- docs/15-rag优化计划.md:74 这是"预期值",不是实测值 —— 面试时要主动澄清 -->
**验证**:`cmd/eval` 对比基线(单查询 vs Multi-Query+RAG-Fusion)的 Recall@1/3/5;预期 Recall 提升 10%+(构造含同义表达的数据集)docs/35-ragas评测/验收报告.md:3,28,32、docs/15-rag优化计划.md:74
追问链:
- 追问:「只有 7 条样本,这个数字有意义吗?」 → 答:「**统计上没有意义,我很清楚。**7 条样本算出来的均值,标准差大到不能用来做决策——换句话说,它只能证明"链路是通的、指标算得出来、降级和容错路径能走通",不能证明"我的检索质量是 0.95 这一档"。所以我给它的定位是 smoke test 加验收证据,不是基线。要变成有用的基线,我的目标是到 100 条以上、覆盖多种问题类型(事实型、推理型、需要跨段落的、故意问不到的),到那时候这组数字才有横向对比的价值。」
- 追问:「那你怎么说服别人这个系统效果是好的?」 → 答:「坦白说,我现在说服不了,只能说它"能跑通、有量化手段"。这一步我会这么讲:一是交付的是一个可执行的评估能力而不是一组数字——任何人拿到数据集跑
cmd/eval就能得到 Recall@1/3/5 和准确性、忠实度;二是评估本身经过了验证,我有 15 个单测覆盖 Recall 判定和指标汇总,Python 侧 86 个测试覆盖队列、限流、状态机、容错;三是报告带配置快照,数据集 hash、judge 模型、RAGAS 版本、prompt 版本全部存档,所以两次评测的差异能归因。如果面试官问我这系统好不好用,我的答案是"我有尺子,但还没量"——比编一个数字诚实,也比编一个数字安全。」 - 追问:「
parse_failure_rate0.92 那次是什么原因?」 → 答:「是我自己写的中文 prompt 的输出格式和 RAGAS 0.3.9 期望的output_model对不上。RAGAS 的 faithfulness 指标要的是对象包裹的 shape,比如{"statements": [...]},我一开始按直觉输出了一个裸数组,框架的 parser 直接抛OutputParserException。修法是把四套 prompt 的输出格式全部对齐到对象包裹,同时保留一个兼容函数能解包旧的裸数组。修完从 0.92 降到 0.0417。这件事教我一个道理:自己覆写框架的 prompt 是高风险操作,输出 schema 必须严格照框架的 output model 来,不能凭语感。」
别踩的雷:
- ❌❌❌ 编任何 Recall@K 或 QPS 或延迟数字。这是全场最重的雷,比答不出来严重得多。
- ❌ 把
docs/15-rag优化计划.md:74的"预期提升 10%+"说成实测结果。 - ❌ 说「7 条样本的 faithfulness 1.0 说明忠实度很好」——7 条里一条编造都没有是正常结果,别过度解读。
- ❌ 回避这题或者说"没统计过"就结束——显得你没把评估当回事。正确姿势是主动交代框架做了什么 + 老实说缺什么 + 给出下一步。
- ✅ 正确说法:RAGAS 四指标 7 样本数字(标注来源)+ 明确说 Go 侧 Recall@K 无实测 + 主动澄清"预期值" + 说明没跑完的真实障碍。
Q15. 线上怎么观测?有没有 metrics 和 tracing?出了一次慢查询你怎么排查?
面试官想考:可观测性的完整度,以及你诚不诚实。这题如果是字节的中高级岗,没有 metrics 是要扣分的,所以策略是"先认,再说清楚你知道该怎么做"。
口述回答(背诵这段): 「先直接回答有没有:日志有,metrics 和 tracing 都没有。
日志用的是 Go 标准库的 log/slog,没自定义 handler,所以是默认的文本格式输出到 stderr,形如 time=... level=INFO msg="HTTP 请求" method=GET path=...。请求日志中间件会打方法、路径、状态码和耗时毫秒。级别控制我做得很弱——只有 cmd/eval 有 -v 开关可以调级别,cmd/server 和 cmd/desktop 都没有日志级别配置项,生产环境没法通过配置调低,只能全量 INFO。
**metrics 和 tracing,全仓库 grep 不到任何 Prometheus、OpenTelemetry 的东西,没有 /metrics 端点。**这是这个项目最明显的可观测性缺口,我不狡辩。唯一的类指标出口是 Python 服务的健康检查里带了队列状态,会返回 {running, queued} 两个数。
审计日志做了,但只覆盖 MCP 工具调用。是个异步 sink:调用方非阻塞投递到一个带缓冲的 channel,后台 worker 写库。队列满了会直接丢弃并打 warn——设计上保证审计绝不拖慢主请求,但代价是会静默丢审计事件,而且我没有对丢弃做计数上报,这是合规风险。参数会截断到 2000 字符并记录截断前的原始长度,结构体里刻意不含任何 token 或密钥字段。
如果真有一次慢查询,按现在的工具我只能这么查:我的请求日志里有耗时毫秒,所以能从日志里找出耗时异常的那条路径。但就到此为止了——因为请求日志没有 request_id、没有用户身份、没有客户端 IP。这意味着我没法把"一条慢请求"和"它背后调了几次 embedding、几次 LLM、检索了多少个 chunk"关联起来。Python 侧我做了 X-Request-ID 的生成和回显,但 Go 侧没做,所以跨服务的链路是对不上的。
正确的做法我很清楚,缺的是三样:一是在 Go 侧也生成 request_id 并贯穿请求和下游调用;二是加 Prometheus 指标,至少要暴露 HTTP 延迟直方图、embedding/LLM 调用次数和延迟、检索召回片段数、入库队列深度这几个;三是分布式 tracing,把一次问答的所有 LLM 调用串成一条 trace。这三样我一个都没做。」
讲解与备注:
- ⭐ 这一题的核心是"不狡辩"。面试官天天看项目,一眼就知道你有没有 metrics。你主动把清单列出来、把排查路径的断点说清楚、把正确做法说清楚,反而会得到"这人知道标准是什么"的评价。
- 把"排查断点"讲具体是关键加分点:「日志有耗时但没有 request_id,所以跨服务链路对不上」——这句话说明你真的想过排查过程,而不是背了"我缺 metrics"。
- 审计只覆盖 MCP 这个事实要主动说。因为审计日志听起来是个加分项,但如果面试官问"普通 API 调用有审计吗",你答"有"就错了。
- 异步审计丢事件这个取舍是一个很典型的工程权衡,可以展开:「我选了"绝不拖慢主请求"而不是"保证不丢"。理由是 MCP 调用是只读 RAG 能力,审计是合规需求而不是业务需求,丢几条不会影响用户。但更好的做法是丢的时候打一个计数器指标,让运维能看见丢弃率,我连这个都没做。」
- 具体指标清单要能报出来(这是"你知道该怎么做"的证据):HTTP 请求延迟直方图、外部 LLM/Embedding 调用次数与延迟、检索返回片段数分布、入库任务队列深度与处理耗时、评测服务队列深度、judge 解析失败率。
代码依据:
// internal/api/middleware.go:88-99 请求日志:有耗时,但没有 request_id / 用户 / IP
// Logger 请求日志中间件(方法 / 路径 / 状态码 / 耗时)
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
slog.Info("HTTP 请求",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"耗时ms", time.Since(start).Milliseconds(),
)
}
}// internal/mcp/audit.go:17-21 异步审计的设计取舍(队列满即丢弃)
// AuditSink 异步审计(plan D8):
// - Submit 非阻塞投递到 buffered channel(队列满丢弃并 warn,不阻塞 MCP 主请求)
// - 后台 worker 从 channel 取事件写入数据库(失败仅 warn)
// - Shutdown 停止接收 → flush 剩余 → 退出 worker(防 goroutine 泄漏,由 App 管理生命周期)# services/ragas-eval/src/ragas_eval/api/middleware.py:57-60 Python 侧有 request_id,Go 侧没有
async def dispatch(self, request: Request, call_next):
# request_id 生成与回显(任务全生命周期日志关联用)
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
request.state.request_id = request_idinternal/api/middleware.go:88-99、internal/mcp/audit.go:17-21、internal/mcp/audit.go:56-60、services/ragas-eval/src/ragas_eval/api/middleware.py:57-75
追问链:
- 追问:「那如果要你现在加 metrics,你会先加什么?」 → 答:「按"能不能定位问题"排序,我先加三个。第一,HTTP 请求延迟直方图按路径分桶——不是平均值,因为平均值会掩盖长尾,我需要 p95 和 p99。第二,外部依赖的调用指标:embedding 和 LLM 和 reranker 各自的调用次数、延迟分布、错误率,因为这三块是 RAG 系统全部的时间成本所在,本地代码几乎不耗时。第三,检索侧的分布指标:每次问答召回多少片段、重排前后的数量、上下文 token 用量——这几个能直接解释"为什么这次回答质量差"。有了这三个,我才能回答"慢在哪一层"。现在的处境是:我知道慢,但我不知道是 embedding 慢、rerank 慢还是 LLM 慢。」
- 追问:「你的日志 key 有的是中文有的是英文,为什么?」 → 答:「**这是我的不一致,没什么好理由。**比如请求日志里前三个 key 是
method、path、status,第四个是"耗时ms"。原因是早期写日志时我随手用了中文,后来意识到结构化日志的 key 应该是机器友好的英文、方便 ELK 或者 Loki 做字段提取,但已经写下去的地方我没回头统一。这是典型的技术债——它不影响功能,但影响可运维性,因为做日志聚合的时候中文 key 要额外做映射。正确做法是全部用 snake_case 英文 key,值可以是中文。」 - 追问:「有没有做过日志脱敏?」 → 答:「审计那条链路我做了,其他没有。审计日志的
store.AuditLog结构体里刻意不含 token 和密钥字段,参数会截断到 2000 字符。但普通请求日志里没有做脱敏——目前我的请求日志只打了 method、path、status、耗时,不涉及 body 和 header,所以实际上没有泄露风险。会出问题的是如果将来有人往里加了 body 日志——/api/v1/chat的 body 里是用户问题,/api/v1/config的 body 里会有 API Key,那就会泄露。理想做法是在中间件里维护一个"敏感路径列表"或者做一个字段级脱敏。"
别踩的雷:
- ❌ 说「有 Prometheus 和 Grafana」——代码中未找到,一个都没有。
- ❌ 说「有分布式 tracing」——没有。
- ❌ 说「所有 API 调用都有审计日志」——只有 MCP。
- ❌ 说「日志可以按级别配置」——只有
cmd/eval能,服务端不行。 - ✅ 正确说法:有 slog 文本日志和请求耗时 + 无 metrics/tracing + 审计只覆盖 MCP 且会静默丢 + 明确说跨服务链路因为缺 request_id 对不上 + 给出该加的三个指标。
Q16. 单机大概能扛多少 QPS?瓶颈在哪?
面试官想考:⭐ **陷阱题。**他等你报一个数字。**绝对不要报。**这题的得分点在于你能不能在没有基准测试的情况下,按架构推出瓶颈在哪——那才是真本事。
口述回答(背诵这段): 「我先说清楚:这个项目我没有做过压力测试,仓库里也没有任何 QPS 或延迟的实测数据,所以我不会给您报一个数字。
但我可以按架构推一下瓶颈在哪,这个我是想过的。
第一个也是最大的瓶颈是外部 API 的限流,不是我的代码。因为 RAG 一次问答的耗时几乎全部花在三个外部调用上:query 改写调一次 LLM、检索调一次 embedding、重排调一次 reranker、然后生成再调一次 LLM。而我对这些调用都装了令牌桶限流:embedding 和 LLM 默认 QPS 都是 10,reranker 也是 10,联网搜索是 1。所以单机的理论吞吐上限就是被这几个外部限流卡住的,大概是一次问答占用四次外部调用,10 QPS 的额度下并发能力很有限。本地代码的处理时间相比之下可以忽略。
第二个瓶颈是 reranker 的超时设计。我的 reranker HTTP 客户端超时是硬编码 30 秒,没有走配置——这是个短板:如果 reranker 服务挂了但是 TCP 还能连上(比如一直不返回),每个请求要等满 30 秒才失败,并发一上来 goroutine 就会堆积。llm.timeout 是可配的默认 60 秒,但 reranker 不是。
第三个是入库的 worker 池。worker_count 代码默认 2、示例配置里我写的是 5,任务是串行的,每个任务要解析文档、分块、批量 embed、写向量库。所以大批量上传的时候入库会成为长尾,而且它会和问答抢 embedding 的 QPS 额度——这是我认为最需要做隔离的地方。
第四个是全局限流默认是关的。server.rate_limit_qps 默认 0 表示不限制,也就是说单机没有入口保护,外部压力可以直接打到 embedding 限流那一层去排队。
优化方向我会按优先级排:一是加缓存——同样的 query 改了改写参数、或者在多轮对话里重复问相似问题,是可以命中缓存的,我现在一层缓存都没有;二是批量化 embedding 调用,我的 embedder 有 batch_size 配置,但检索路径上是单条查,只有入库路径是批量的;三是给 reranker 超时改成可配置并且调小;四是把入库和问答的 embedding 额度分池;五是加真正的压测。在这五条做完之前,任何 QPS 数字都是猜的。」
讲解与备注:
- ⭐ 开场第一句就拒绝报数字,这是本题最重要的动作。面试官问"能扛多少 QPS",很多时候就是要看你会不会张口就来。你明确说"没压测过、不报数字",然后立刻转向"但我知道瓶颈在哪",这是满分答案的形状。
- 报出的配置数字要准确,这些是代码里的真实默认值:embedding QPS 10(
config.go:387-389)、LLM QPS 10(:438-440)、reranker QPS 10(:431-433)、web_search QPS 1(:460-462)、worker_count默认 2(:534-536示例配置写 5)、rate_limit_qps默认 0 即不限(middleware.go:116-119)、llm.timeout默认 60(:441-443)。 internal/reranker/reranker.go:68的 30 秒超时是硬编码——这个发现很值钱,因为它是一个真实的、可被面试官验证的短板,说明你真读过自己的代码。- 「入库和问答抢 embedding 额度」这个洞察体现了对资源竞争的理解,比说"我要加缓存"更有深度。
- 「一层缓存都没有」也要主动说:整个仓库 grep 不到 Redis、也没有内存缓存,只有 HTTP 的
Cache-Control头。别让面试官以为你有缓存。
代码依据:
// internal/reranker/reranker.go:68 30 秒超时硬编码,没有走配置
client: &http.Client{Timeout: 30 * time.Second},
// internal/reranker/reranker_llm.go:49 另一条重排链路也是同一处硬编码
client: &http.Client{Timeout: 30 * time.Second},// internal/api/middleware.go:115-119 全局限流,qps<=0 就不限(默认 0)
// RateLimit 全局限流中间件;qps <= 0 时不限制
func RateLimit(qps int) gin.HandlerFunc {
if qps <= 0 {
return func(c *gin.Context) { c.Next() }
}
limiter := rate.NewLimiter(rate.Limit(qps), qps)// internal/config/config.go:381-389 限流默认值(瓶颈的真正来源)
if c.Embedder.BatchSize <= 0 {
c.Embedder.BatchSize = 100
}
...
if c.Embedder.QPS <= 0 {
c.Embedder.QPS = 10
}internal/reranker/reranker.go:68、internal/reranker/reranker_llm.go:49、internal/api/middleware.go:115-129、internal/config/config.go:381-389、internal/config/config.go:431-433、internal/config/config.go:438-443、internal/config/config.go:534-536
追问链:
- 追问:「如果 QPS 上不去,你第一个改哪儿?」 → 答:「改缓存,因为它是投入产出比最高的。RAG 的请求有明显的重复性——同一个知识库里相似的问题很常见,而且实时性要求没那么高。我会做两级:一级是query 改写和 HyDE 这类 LLM 调用的结果缓存,同样的原始问题加同样的策略参数,结果可以直接复用;二级是 embedding 结果的缓存,按文本内容的 hash 做 key。这两级都能直接省掉一次外部调用,而外部调用就是我刚才说的瓶颈。会遇到的坑是缓存失效:文档重新入库之后,基于旧向量的检索结果就不对了,所以缓存 key 里得带上知识库的版本号或者文档更新时间戳。」
- 追问:「没有压测数据,你怎么知道加了缓存有效?」 → 答:「**所以我会先补压测,再加缓存。**顺序不能反。我的做法是用
k6或者vegeta写一个脚本,固定并发数打/api/v1/chat,把外部 LLM 和 Embedding 换成 mock 服务——这一步很关键,因为不打桩的话测出来的是供应商的延迟,不是我的系统的能力。然后我要看三个数:p95 和 p99 延迟、error rate、以及各层的耗时占比。有了这个基线,加缓存之后才能说"p95 从多少降到多少"。现在的状态是:我知道瓶颈在外层,但没有量化证据,所以我不给数字。」 - 追问:「
server.rate_limit_qps默认 0 不限制,这样安全吗?」 → 答:「不安全,默认应该给一个值。我当时设 0 是为了本地开发方便——不想让限流干扰调试。但代价是任何部署忘了配这个值,入口就是敞开的。更麻烦的是它和我已知的缺陷叠加:internal/llm/llm.go和internal/embedding/embedder.go里初始化限流器时写的是rate.NewLimiter(rate.Limit(cfg.QPS), cfg.QPS),没有对 QPS 小于等于 0 做防御——只有 reranker 做了,它会兜底成 10。所以理论上如果 QPS 被配成 0,rate.NewLimiter(0, 0)的Wait会永久阻塞。目前没爆是因为applyDefaults会把 0 兜底成 10,所以正常加载配置走不到那个分支——但这是一层只有防御性编程才能兜住的隐患,如果有人绕过LoadConfig直接构造 config,就会踩到。」
别踩的雷:
- ❌❌ 报任何 QPS、TPS、延迟毫秒数。仓库中没有压测数据,报了就是编。
- ❌ 说「我们的瓶颈在数据库」或者「在 Go 的 GC」——RAG 的瓶颈在外部 API 调用,这个判断错了会被认为你不懂这个系统。
- ❌ 说「有 Redis 缓存」——一层缓存都没有。
- ❌ 说「reranker 超时可以配置」——硬编码 30 秒。
- ❌ 说「QPS=0 的限流器能正常工作」——
rate.NewLimiter(0,0)的Wait会永久阻塞,只有 reranker 有兜底。 - ✅ 正确说法:明确拒绝报数字 + 四个瓶颈(外部限流 / reranker 硬编码超时 / worker 池 / 入口不限流)+ 五个优化方向 + 强调"先压测再加缓存"。
Q17. 你都写了哪些测试?CI 里强制跑了哪些?你自己怎么验证一个功能是好的?
面试官想考:测试意识,以及你有没有真实可指认的测试证据(这题面试官很可能会让你现场报测试函数名)。
口述回答(背诵这段): 「我报一下实际的数量:Go 侧 63 个测试文件、405 个测试函数;前端 vitest 37 个用例;Python 评测服务 86 个测试函数。
分布上很不均,我说实话:internal/rag 最多有 85 个,internal/api 68 个,internal/loader 36 个,internal/auth 30 个。但 internal/app 是 0 个——这是我最不满意的地方,因为 internal/app 是 392 行的装配加优雅退出的核心,恰好是"错了很难发现"的那一层。cmd/desktop 和 cmd/eval 也是 0 测试。
CI 强制的:ci.yml 里跑 gofmt -l 检查格式、go vet ./...、go test ./...,前端跑 vue-tsc --noEmit 加构建再加 vitest,另外对 store、task、api、eval 四个包跑 -race。Python 那 86 个测试没有进 CI,只在本地跑过,这是我的缺口。
我自己验证功能的路径,我举两个具体例子。
第一个是部署形态这条链路,我用的是测试函数级的验证:internal/webui/router_test.go 有四个用例,TestRootServesIndex 验证根路径返回 HTML、TestSPADeepLinkFallback 验证深链回退、TestAPIMissReturnsJSON404 验证 API 未匹配返 JSON 而不是 HTML、TestAssetsServed 验证 assets 下不存在的文件不会被回退成 index.html。最后那条是我专门加的,防的是"HTML 被当 JS 解析"那个经典坑。
第二个是配置解析的边界:cmd/server 的 TestParseConfigFlag 是一个表驱动测试,覆盖了 8 种情况——短横线 -c、双横线 --config、单横线 -config、无参数、空参数列表、缺值、未知参数、混合顺序。我用一个 ParseConfigFlag 函数绑两个别名就是靠这个测试兜住的。
前端这块我专门测了两个"最容易错但最难人肉发现"的东西:一是 SSE 解析,api/chat.test.ts 6 个用例里有"非 JSON 的 data 行要跳过但后续事件还得继续解析"、"非 2xx 时要取后端的 message 而不是 HTTP 状态码"、"后端返 502 且 body 不是 JSON 时要回退成状态码";二是流式降级状态机,stores/chat.test.ts 12 个用例,用 mock 的 chatStream 驱动事件序列,验证 error 事件、用户主动停止、网络失败、空流这四种降级路径下的消息状态和 streaming 标志。
Python 那块是测试密度最高的:13 个测试文件 86 个用例,测了任务队列、状态机合法性、限流令牌桶、采集器的重试与降级、路由的幂等和 409 冲突。mock 用的是 respx 拦 httpx。」
讲解与备注:
- ⭐ 能现场报出测试函数名,是这题的核心得分点。
TestSPADeepLinkFallback、TestAPIMissReturnsJSON404、TestParseConfigFlag这三个名字要背下来——它们具体、可验证、能体现设计意图。 - 主动说
internal/app零测试是必须的。因为面试官如果问"装配和优雅退出怎么测的",你答不出来会很尴尬。主动说 + 给出原因("装配依赖外部 PG 和 Qdrant,我当时的取舍是没有做接口抽象所以没法注入 mock")比被问出来强。 - 「表驱动测试覆盖 8 种边界」这种具体数字能体现你的测试是想过的,不是凑的。
- Python 测试密度最高这一点值得强调,因为它是"一个新人独立开发的服务"却测试最全,反差本身是加分项。
- 可以补一句测试策略的自省:「如果重做,我会优先给
internal/app做接口抽象的依赖注入——现在app.New直接store.NewStore(dsn),没法注入 fake。如果先把 store 和 vectorstore 抽成接口(其实已经有接口了,store.Store和vectorstore.VectorStore都是接口),装配层是完全可以测的。这是我当时偷懒留下的洞。」
代码依据:
// internal/webui/router_test.go:37-49 深链回退
func TestSPADeepLinkFallback(t *testing.T) {
r := newTestRouter(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/kb/some-id", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /kb/:id 状态码 = %d, want 200(SPA 回退)", w.Code)
}
if !strings.Contains(w.Body.String(), "<html") {
t.Fatalf("深链应回退 index.html,实际: %s", w.Body.String())
}
}// cmd/server/main_test.go:11-27 表驱动覆盖 8 种 flag 边界
func TestParseConfigFlag(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"短横线 -c", []string{"-c", "configs/config.local.yaml"}, "configs/config.local.yaml"},
{"双横线 --config", []string{"--config", "configs/config.yaml"}, "configs/config.yaml"},
{"单横线 -config 等价", []string{"-config", "a.yaml"}, "a.yaml"},
{"无参数", nil, ""},
{"空参数列表", []string{}, ""},
{"缺值", []string{"-c"}, ""},
{"未知参数忽略", []string{"-x", "1"}, ""},
{"混合顺序", []string{"-c", "x.yaml", "extra"}, "x.yaml"},
}.github/workflows/ci.yml:64-75、internal/webui/router_test.go:37-49、cmd/server/main_test.go:11-27、frontend/src/api/chat.test.ts:1、frontend/src/stores/chat.test.ts:1
追问链:
- 追问:「
internal/app为什么没测试?」 → 答:「**两个原因,第二个是根因。**直接原因是当时排期紧,装配层"能跑起来"就往下走了。根因是app.New里是硬编码构造的——它直接store.NewStore(ctx, cfg.Postgres.DSN)、vectorstore.NewQdrantStore(cfg.VectorStore),然后依次Migrate、seedAPIKey、EnsureCollection。虽然store.Store和vectorstore.VectorStore已经都是接口,但New里没有留注入口,所以测它必须起真实的 PostgreSQL 和 Qdrant。正确做法是把New拆成"构造依赖"和"装配依赖"两段,前者可注入。我认这个洞,因为装配层恰恰是"错一行就整个服务起不来"的地方。」 - 追问:「为什么 Python 测试比 Go 还密?」 → 答:「因为那个服务是我从零新建的,没有历史包袱,所以一开始就按 TDD 的节奏写——队列、限流、状态机、采集器这些纯逻辑都能测,用
respx拦 httpx 就能把外部依赖打掉,测试写起来成本很低。而 Go 侧是增量演进的,早期模块(比如internal/app)是在还没有测试习惯的时候写的,后面加了新模块(internal/eval15 个用例、internal/mcp20 个用例)测试就比较全。这个对比本身说明:测试密度更多取决于"写的时候有没有留可测的缝",而不是"这个语言好不好测"。」 - 追问:「前端 37 个用例够吗?」 → 答:「不够,而且偏得厉害。37 个里有 18 个集中在 SSE 解析和流式降级状态机——这是我判断风险最高的地方,因为手写 SSE 解析很容易出边界 bug、而状态机出错会导致 UI 卡死。但组件级测试基本是空白,比如引用来源卡片、上传面板、报告图表这些都没测。而且没有 E2E,验收记录里我自己也写了"AC8 前端浏览器实操走查"因为环境限制没做完。要补我会用 Playwright 做一条主链路 E2E:登录、建库、传文档、问答、看引用、发起评测、看报告。」
别踩的雷:
- ❌ 说「测试覆盖率 80%」——仓库中没有覆盖率统计,没有
-cover步骤,别编。 - ❌ 说「所有模块都有测试」——
internal/app、cmd/desktop、cmd/eval都是 0。 - ❌ 说「Python 测试也在 CI 里跑」——没有。
- ❌ 说「race 全包都跑」——只跑了 4 个包。
- ✅ 正确说法:报准确数量 + 报分布不均 + 背出具体测试函数名 + 主动说
internal/app零测试及根因 + 说无 E2E。
Q18. 从零到跑起来,部署体验是什么样的?有 docs 和工具吗?
面试官想考:交付体验——你交付的是"能跑的项目"还是"只有你能跑的项目"。
口述回答(背诵这段): 「最省事的一条路是 docker compose up -d --build。compose 文件会把 PostgreSQL、Qdrant 还有评测服务一起拉起来,然后打开 8085 就能用。唯一的前置动作是把 deploy/configs/config.docker.yaml 里的模型地址和 key 填上——配置文件是只读挂载进去的,没挂载会启动失败,这是故意的 fail-fast,避免有人用默认配置跑起来一个看起来很正常的服务。跑起来之后我再给一个 docker compose -f docker-compose.prod.yml up -d 的路径,那个是不现场构建、直接拉 ghcr 上已发布镜像的,适合部署环境。
**开发场景我准备了两条。**一条是 docker compose -f docker-compose.dev.yml up -d,只起 PostgreSQL 和 Qdrant 给宿主机用,项目名是 binrag-dev,和部署环境的卷、网络完全隔离,不会串数据;然后本地 go run ./cmd/server -c configs/config.local.yaml 跑后端、pnpm dev 跑前端,Vite 会把 /api 代理到 8085,这样前端有完整热重载。另一条是给桌面版的,有 Taskfile.yml,里面 6 个任务:build(前端加二进制)、build:frontend、build:binary、run(直接 go run ./cmd/desktop 加载本地配置)、package(组装 .app 加 adhoc 签名)、package:dmg(出 dmg)。
配置这一块我做了一层便利:主配置之外会自动找同名的 .local.yaml 合并。所以开发时把密钥和个人地址写在 config.local.yaml 里就行,.gitignore 忽略 *.local.* 但放行 *.local.yaml.example 模板,密钥不会进版本库,也不会污染主配置。
文档我写了 docs/dokploy-deploy.md 和一份中文的 dokploy 部署指南,覆盖的是把镜像部署到 Dokploy、再用 Cloudflare 做 DNS 代理和 HTTPS 这条路径。另外仓库里有 deploy/configs/ 放配置模板、configs/config.yaml 是带完整注释的示例配置——这个示例配置里每一项我都在旁边写了注释说明含义和默认值,这是给使用者最直接的文档。
我要说两个不足:一是 Taskfile.yml 只覆盖桌面形态,没有 test、lint、docker 这些常用任务,Web 形态的构建只能靠 Dockerfile 或者手工敲命令;二是仓库里有四个 compose 文件,dev/prod/local/一键,新人容易搞不清该用哪个,而且生产那两个还不包含评测服务。要改的话我会写一个 README 的"快速开始"表格把四条路径列清楚。」
讲解与备注:
- 「fail-fast 是有意设计」这句话要说,它把一个"没挂配置就崩"的行为解释成了工程决策。代码里
Dockerfile:82-85的注释就是「未挂载配置时服务启动即失败(fail-fast),避免误用默认值运行」,configs/config.yaml全文带逐项中文注释。 - 「dev 环境和部署环境的 project name 隔离」这个细节说明你踩过"开发数据串到生产"的坑(或至少想过)。
- 主动说 Taskfile 只覆盖桌面很加分——因为面试官如果问"有 Taskfile 吗,跑个测试看看",你会发现没那个任务。
- Dokploy 那两份文档是真实存在的:
docs/dokploy-deploy.md和docs/部署指南-dokploy.md。提的时候可以说"写了两份,一份英文名一份中文名,其实是重复的"——这种自嘲式的诚实很加分。
代码依据:
# Taskfile.yml:9-29 全部 6 个任务,注意没有 test / lint
tasks:
build:
desc: 构建桌面应用(前端产物 + Go 二进制)
cmds:
- task: build:frontend
- task: build:binary
build:frontend:
desc: 构建前端产物(输出到 internal/webui/dist,被 go:embed 打包)
cmds:
- cd frontend && npm run build
run:
desc: 本地运行桌面应用(默认加载 configs/config.local.yaml)
cmds:
- go run ./cmd/desktop -c configs/config.local.yaml# docker-compose.dev.yml:8-19 只起依赖,且项目名隔离
# 注意:
# - 本文件不构建/不运行 BinRag 服务本身(开发时进程跑在宿主机,便于热重载调试)。
# - 完整一键部署(构建镜像 + 数据库 + 运行)请用仓库根目录的 docker-compose.yml。
# - 项目名固定为 binrag-dev,与部署环境的卷/网络隔离,互不干扰。
name: binrag-dev
services:
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333" # REST API
- "6334:6334" # gRPC// internal/config/config.go:347-358 .local.yaml 自动合并(开发便利 + 密钥不入库)
// 自动合并本地覆盖文件(<主文件名>.local.yaml)
if lp := localOverridePath(path); lp != "" {
localData, err := os.ReadFile(lp)
if err != nil {
slog.Warn("读取本地配置覆盖文件失败,忽略 local 覆盖", "path", lp, "err", err)
} else if err := yaml.Unmarshal(localData, &cfg); err != nil {
slog.Warn("解析本地配置覆盖文件失败,忽略 local 覆盖", "path", lp, "err", err)
} else {
slog.Info("已合并本地配置覆盖", "local", lp, "main", path)
}
}Taskfile.yml:9-47、docker-compose.dev.yml:8-19、internal/config/config.go:347-378、Dockerfile:82-85、docs/dokploy-deploy.md、docs/部署指南-dokploy.md
追问链:
- 追问:「新人拿到仓库,第一次怎么跑起来?」 → 答:「按我现在的文档,最短路径是三步:
docker compose up -d --build起全栈,改deploy/configs/config.docker.yaml填模型 key,打开localhost:8085。但这里有个真实的不顺——那个配置模板里没有eval段也没有multimedia段,而 compose 的注释却让人去那里配评测服务的地址,所以想用评测中心的人得自己补这两个段落。我核对过,configs/config.yaml有 14 个顶层配置段,deploy/configs/config.docker.yaml只有 12 个,差的就是这两段。这是我该修的,模板和文档不一致是最容易让新人卡住的地方。」 - 追问:「前端为什么要单独跑?不能直接跑后端吗?」 → 答:「因为后端二进制里的前端是构建时烤进去的。如果你只
go run ./cmd/server,它会用internal/webui/dist里上一次构建的产物——如果你刚改了前端代码但没重新 build,你看到的是旧页面。所以开发前端必须走pnpm dev让 Vite 提供热重载,vite.config.ts里配了/api代理到127.0.0.1:8085,这样前端在 5173、后端在 8085,浏览器的请求经 Vite 转发过去。这就是单二进制方案在开发期的代价——生产享受一致性,开发时要多起一个进程。」 - 追问:「Dokploy 那份文档写的是什么?」 → 答:「是把镜像部署到 Dokploy 的完整步骤:怎么用
Dockerfile.deploy配构建、怎么挂配置文件和数据卷、PostgreSQL 和 Qdrant 怎么处理,以及前面挂 Cloudflare 做 DNS 代理和 HTTPS 的方式——因为我的服务本身不处理 TLS,是让反向代理终止的。文档里也写了配置文件的必填项。有一点要说明:这份文档是部署指引,我没有在真实 Dokploy 环境上端到端验证过,验收记录里也写了 Docker 镜像构建那次因为本机网络拉取 base image 超时没跑完,只做到"Dockerfile 被构建器完整解析"这一步。所以这部分我只能说"文档写全了",不能说"验证过了"。」
别踩的雷:
- ❌ 说「一条命令就能跑」——还差填配置,而且
config.docker.yaml缺两段得手工补。 - ❌ 说「Taskfile 里能跑测试」——没有 test 任务。
- ❌ 说「Dokploy 部署验证过了」——验收记录里明确写了 Docker 镜像构建因网络超时未完成(
docs/35-ragas评测/验收报告.md:49)。 - ❌ 说「有 k8s 部署」——只有 compose。
- ✅ 正确说法:三条路径(一键 / 免编译 / 开发)+ Taskfile 6 个任务 +
.local.yaml合并机制 + 主动说 Taskfile 只有桌面 + 模板缺段 + Dokploy 文档未端到端验证。
三、诚实承认的不足与改进
| # | 不足 | 事实依据(文件:行号) | 面试话术 | 改进方案 |
|---|---|---|---|---|
| 1 | 无 metrics / tracing | 全仓库 grep -rn "prometheus|opentelemetry|/metrics" 零命中 | 「日志有、指标没有。我知道该怎么补,清单都想好了。」 | 先加 HTTP 延迟直方图(非均值)、外部依赖调用指标、检索片段数分布 |
| 2 | Go 侧无 /healthz,容器探针只打 Swagger 页 | docker-compose.yml:70 打 /swagger/index.html;internal/api/ 无 health 路由 | 「探针只验证 HTTP 活着,不验证 PG 和 Qdrant 连通性。」 | 加 /healthz 真的 SELECT 1 加 ping Qdrant;Python 侧已经是这么做的 |
| 3 | Python 评测服务零 CI | grep -rn "services/ragas-eval" .github/ 返回空 | 「86 个 pytest 和 ruff 只在本地跑过,没进流水线。」 | 加 Python job:uv sync --frozen + pytest + ruff check |
| 4 | internal/app 零测试 | ls internal/app/*_test.go 无结果(392 行装配核心) | 「装配层需要真实 PG 和 Qdrant 才能测,我当时没留注入口。」 | 把 New 拆成"构造依赖"和"装配依赖",利用已有的 store.Store / vectorstore.VectorStore 接口注入 fake |
| 5 | 无任何压测与性能数据 | 全仓库无 hey/wrk/k6/vegeta 痕迹;无 QPS/延迟记录 | 「我不报数字,因为我没测过。但我知道瓶颈在外部 API 限流。」 | 用 k6 打 /chat,把 LLM/Embedding 换成 mock 才能测出自己的能力 |
| 6 | 一层缓存都没有 | grep -rn "redis|Cache" internal/ cmd/ 只命中两处 HTTP Cache-Control 头 | 「没有 Redis、没有内存缓存,重复 query 也在重复烧外部调用。」 | query 改写结果缓存 + embedding 内容 hash 缓存,key 里带知识库版本号 |
| 7 | 完整评估基线没跑完,Go 侧 Recall@K 无实测数字 | 仓库中未找到任何 Recall@K 实测值;docs/15-rag优化计划.md:74 的「提升 10%+」是预期值 | 「评估框架落地了、跑通过端到端,但完整基线我还没跑完。」 | 先把数据集扩到 100+ 条、覆盖四类问题,再跑基线 |
| 8 | 数据集 expected_ids 需人工标注且无工具;chunk ID 会变导致数据集失效 | internal/eval/dataset.go:14-19 定义字段;docs/35-ragas评测/prompt-design.md:513 承认「标注成本高」 | 「改分块策略就得重标数据集,这是最需要评估的场景反而最难评。」 | 用 chunk 内容的稳定指纹替代 ID;做标注辅助工具 |
| 9 | docker-publish 不依赖 CI,测试失败也会推镜像 | .github/workflows/docker-publish.yml 无 needs: | 「两条 workflow 各自触发,镜像可能在测试挂的情况下推上去。」 | 合并成一条 workflow 用 needs: build-and-test,或用 workflow_run |
| 10 | -race 只覆盖 4 个包,internal/rag(85 个测试、含并发)没跑 | .github/workflows/ci.yml:75 | 「我按风险挑的,但挑错了——最该跑 race 的就是 rag。」 | 全量 -race,至少补 internal/rag 和 internal/retriever |
| 11 | 配置热重载在容器下失效(配置文件 :ro 只读挂载,写不回去) | docker-compose.yml:61 有 :ro;internal/config/manager.go:62-67 要写文件 | 「Web 界面改配置在容器里走不通,只能改宿主机文件重启。」 | 挂载可写配置目录,或热重载只更新内存快照不回写文件 |
| 12 | 桌面版:无信号处理、adhoc 签名、忽略 server.port、相对路径依赖 CWD | cmd/desktop/main.go 无 os/signal;.github/workflows/release.yml:128 codesign -s - | 「桌面版是薄壳,发布前还差公证。」 | 加 signal.Notify;买开发者证书走 notarization;启动时 chdir 到二进制目录 |
| 13 | Bootstrap API Key 依赖 + 改配置需 bootstrap 权限 | internal/app/app.go:308-332;internal/api/handler_config.go:194-195 返 403 | 「改配置只有 bootstrap key 能做,普通 key 是 403。」 | 加角色模型(admin/editor/viewer),把 bootstrap 降级成一次性初始化 |
| 14 | config.docker.yaml 模板缺 multimedia 和 eval 两段 | 实测顶层段 12 个 vs configs/config.yaml 14 个;docker-compose.yml:83 却让人去那配 | 「模板和文档不一致,新人会卡在这。」 | 补齐模板两段并加注释 |
| 15 | Dockerfile 与 Dockerfile.deploy 逐行重复 ~95 行 | Dockerfile:1-94 vs Dockerfile.deploy:1-95 | 「两份重复文件,改一处忘另一处。」 | 抽公共 base 或用 build arg 区分 |
| 16 | 审计日志只覆盖 MCP,且队列满静默丢弃 | internal/mcp/tools.go:124 唯一写入点;internal/mcp/audit.go:17-21 | 「普通 API 没有审计;队列满丢事件不打计数器。」 | 请求级审计 + 丢弃计数器指标 |
| 17 | reranker HTTP 超时硬编码 30s,不可配置 | internal/reranker/reranker.go:68、internal/reranker/reranker_llm.go:49 | 「连不上的情况下每个请求等满 30 秒,并发一上就堆 goroutine。」 | 提成配置项并调小;加熔断 |
| 18 | embedder/llm 的 QPS 限流器无 qps<=0 防御 | internal/embedding/embedder.go:38、internal/llm/llm.go:128;只有 internal/reranker/reranker.go:45-52 做了兜底 | 「目前靠 applyDefaults 兜住,但绕过 LoadConfig 构造就会 Wait 永久阻塞。」 | 三处都加 newLimiter 风格的兜底 |
四、背诵清单
- 双形态靠
internal/app复用:cmd/server和cmd/desktop都调app.New和a.Router(),差异只有 HTTP 怎么起——Web 是:8085+ListenAndServe,桌面是net.Listen("127.0.0.1:0")拿随机端口再Serve(ln),因为必须先绑定才能读出端口给窗口用。 - 同源所以没有 CORS:桌面窗口加载的就是那个
http://127.0.0.1:端口/,前端请求全是相对路径/api/v1/...,同一个 Gin Router 服务,完全不依赖 Wails IPC;代价是前端改一行要pnpm build加go build重编,干净仓库必须先 build 前端才能编译 Go。 - 单二进制 = go:embed + SPA 四条路由规则:
/给 index.html、/assets/*长缓存、其余 GET 回退 index.html、/api/前缀和非 GET 返 JSON 404(否则 API 404 会被吞成 HTML);embed FS 不支持c.File,要fs.ReadFile加c.Data。 - 容器三阶段:Node 22(glibc 不是 alpine,因为 vite 的 Rust napi 模块只有
-gnu变体)建前端 → golang:1.26-alpine 加 Alpine 用CGO_ENABLED=0 -trimpath -ldflags="-w -s"编静态二进制 → alpine:3.20 只装 ca-certificates、tzdata、ffmpeg 加固定 uid 1000 用户,只 COPY 一个二进制;镜像不含任何配置,没挂载就启动失败(fail-fast)。 - CI 三条 workflow:
ci.yml跑 gofmt、vet、go test ./...、前端 vue-tsc 加 vitest、对 4 个包跑 race;docker-publish.yml多架构推 ghcr;release.yml在v*tag 上交叉编译 5 个平台(linux/darwin 的 amd64/arm64 加 windows amd64)、macOS 出 dmg、Windows 出 zip 再发 Release。诚实点:Python 服务零 CI,docker-publish不依赖 CI。 - 配置三层优先级 +
.local.yaml自动合并 + 请求级原子快照:-c>BINRAG_CONFIG> 默认路径;主配置后自动合并同名.local.yaml(密钥不入库);ConfigManager用atomic.Pointer做请求级快照,更新是"校验 → 试构建(失败就保持旧配置)→ 原子写文件 → 原子替换"四步。能热改的是 LLM/Embedding/Retriever/Reranker,必须重启的是端口、worker 数、Qdrant 和 PG 连接。 - Recall@K 公式:
命中样本数 / 有效样本数,有效样本剔除了检索报错的、以及expected_ids为空的;单样本判定是"任一期望片段出现在前 K 就记命中",且只发一次检索请求取maxK。不用 MRR/NDCG 是因为标注只有"片段 ID 集合"、没有相关性等级,强行算会退化成二值版本。 - LLM-as-Judge 两个维度 + 温度 0:准确性 0-10 分四档锚点(9-10 / 6-8 / 3-5 / 0-2)、忠实度布尔;固定
temperature=0求确定性;输出强制 JSON 并用"首个{到末个}"容错提取;用*float64指针区分"未评"和"评了 0 分"。弱点要主动说:Go 侧忠实度只喂了文件名和标题,没喂正文。 - Python RAGAS 是独立微服务,按边界拆的:服务面留 Go(部署加并发),评测面给 Python(吃 RAGAS 生态);Go 用
httputil.ReverseProxy纯透传并注入X-Eval-Internal-Token,唯一业务逻辑是提交任务时校验kb_id越权。三件事要记住:三层限流(任务级 2 / 排队 16 满则 429 / 样本级 4-8 / 令牌桶 60rpm)、幂等返回 200 不是 201、UNIQUE(task_id, idx)支撑样本级断点续跑,取消是样本边界协作式不硬断。 - ⭐ 效果数据只报有出处的,其余一律说没有:RAGAS 四指标在 7 样本联调里是 faithfulness 1.0 / relevancy 0.78 / precision 0.83 / recall 0.95,
parse_failure_rate从 0.92 修到 0.0417(docs/35-ragas评测/验收报告.md:3,28,32);Go 侧 Recall@K 没有任何实测数字,docs/15-rag优化计划.md:74的"提升 10%+"是预期值不是结果——面试时就用这句:「评估框架已经落地并跑通了端到端,但完整基线数据我还没跑完。」
附:最容易被问倒的 5 个「一句话自保」
- 被问 QPS/延迟数字 → 「没做过压测,不报数字。瓶颈在外部 API 限流:embedding 和 LLM 默认 QPS 都是 10,reranker 超时硬编码 30 秒。」
- 被问 Recall 提升多少 → 「Go 侧没有实测基线,只有 RAGAS 一套 7 样本的联调数据,统计意义很弱。」
- 被问 metrics/tracing → 「日志有、指标和 tracing 没有。该加什么我想清楚了,只是没做。」
- 被问热重载 → 「LLM/Embedding/Retriever/Reranker 能热改,端口和 worker 数要重启;而且容器里配置文件是只读挂载,这条路径实际走不通。」
- 被问桌面版定位 → 「它是同一个后端加一个 WebView 壳,还是要连外部 PostgreSQL 和 Qdrant 的,不是纯本地应用。」