DocResearch 项目面试:把每个模块讲明白,而不是背术语
这篇文章默认你已经运行过 demo,并大致理解 项目理解文档 中的例子。面试时不需要把本文一次讲完。你的目标是:别人随便指一个模块,你都能说明它为什么存在、输入输出是什么、核心代码怎么工作、还有什么做不到。
每个重点模块都按"先理解 -> 看实现 -> 面试表达 -> 接住追问"的顺序写。口述段落是表达参考,不是要你背成自己没有理解的经历。
不要从头背术语。 先用第 1 节讲清"用户交给我什么、程序返回什么",再练第 3、5、7、9 节的四个核心问题:模型怎么执行工具,资料怎么找到,为什么拆子任务,结论怎么带出处。接口字段、数据库参数和异常细节用于接追问,不要全部塞进开场。
面试准备可直接进入 简历四条贡献、上下文与文件隔离、设计模式追问。遇到不会解释的字段,再回对应模块学习;第 13 节给出现场演示路线,第 15 节用于不看答案自测。
每个模块按三层准备
- 先讲一个动作: 例如"主让 A 查 pgvector、B 查 Milvus,拿到结果再汇总"。
- 再讲怎么实现: 例如"每个子新建一份对话列表,复用同一个 loop 函数"。
- 最后讲取舍: 例如"简单问题不拆,避免多出派发和汇总调用"。
如果第一层还说不清,不必先背 Supervisor、DAG 或 seen。先回理解文档找同一例子。下面的口述是表达参考;需要能打开代码解释,才能把它当自己的项目经历。
1. 第一问:你这个项目是干什么的
先别报技术栈
如果一上来就说"基于 Python、Pydantic、BM25、RRF、多 Agent",面试官知道了名词,却不知道产品做什么。
先回答四件事:谁使用、输入什么、解决什么问题、输出什么。
本项目面向需要整理本地资料的人。用户提供一个问题和一个 Markdown/TXT 目录,程序找证据、拆分独立研究任务,最后生成带来源的报告。解决的不只是"给个答案",而是"回答之后还能检查依据"。
30 秒版本
我做了一个基于本地资料的调研助手。用户给一个问题和一组文档,它查找相关段落、整理笔记,最后生成带文件出处和行号的报告。简单问题由主 Agent 自己完成;比较多个方面时,再交给子 Agent 分别研究。找不到依据的内容会列成资料缺口,方便人检查,而不是只给一段看不出来源的回答。
说到这里可以停,等面试官挑一个点,不必急着报所有限制数字。
两分钟版本
我把 Python 的 Agent 工具循环和 Agentic RAG 放进了同一个调研场景。例如,用户有几份关于 pgvector、Milvus 的资料,想知道两者部署维护的区别。程序要做的不只是生成答案,还要让人知道答案来自哪段资料。
主 Agent 可以自己搜索、读原文和写笔记。问题需要分工时,可以让一个子 Agent 查 pgvector,另一个查 Milvus。每个子有自己的对话和笔记目录,交回结论、出处及没查到的内容,主不用再接收它们全部的阅读过程;有疑问仍能自己读原文核验。
搜索时,先把问题改成几种合适的查法。ES 找关键词,Milvus 找意思相近的段落,合并后再排序,并检查这些内容是否足够回答问题。比如资料只列了监控和备份要求,没有费用数字,就不能直接说哪个更便宜;程序最多再补查一次,仍不足就保留缺口。
我用 Python 实现这些工具,模型只选择工具和参数,执行前由代码检查。所有 Agent 共用调用次数上限,各自只能修改允许的文件;最终报告由程序统一保存,并保留原文、行号和版本。引用存在不代表推理一定正确,这个边界我也在报告中说明。
它是命令行项目,不是上线的多用户平台。我做了自动化测试和受控真实调用,跑通过主直接处理,以及两个研究子任务加一个依赖核验任务的复杂流程。复杂样例实际用了 62 次请求,显式上限是 80;默认 48 次预算是否足够、更多任务能否稳定完成,我没有用这一个样例下结论。
如果被问"你到底整合了哪两部分"
回答要落到行为,不要只说"融合架构":
- RAG 部分负责把问题变成查询,从资料中返回候选证据。
- Agent 部分负责决定下一步用哪个工具、把结果交回模型、派发研究子任务。
- 文件部分负责保存原文版本和最终报告,让结果可以复查。
- 三者由同一个
ResearchRun串起来,不是先运行一个脚本,再手动复制答案给另一个。
2. 要求你画架构,先画这张图
text
用户问题 + 资料目录
|
v
CLI 装配对象
|
v
Corpus: 文件 -> Source 快照
|
v
AgenticSearch: 改写 -> ES/Milvus + RRF -> 重排 -> 评估/有限补检索
^
| search / read_source
|
主代理 loop <------ task 结果:限长发现/缺口 + 引用 ID + 文件元数据
| ^
| plan -> task(依赖就绪) |
+----> 子代理 loop ------------+
| 独立上下文 + 私有工作区
| 主也能直接 search/read/write/edit,不必派发
|
| save_report
v
结构检查 + 引用检查
|
v
run / ArtifactWriter: 状态、报告和运行记录
画完补一句:模型调用在 provider,执行在 runtime,任务依赖与发现登记在 coordination,文件权限在 workspace,检索策略在 agentic,正式存储在 stores,专用重排在 rerank。
不要一口气背出所有文件名。真正被问"这个功能怎么实现"时,用下面的入口找到负责它的代码:
| 面试官问的动作 | 你先解释什么 | 对应代码 |
|---|---|---|
| 模型怎样操作电脑里的文件? | 模型返回工具名和参数,Python 检查后执行,不是模型直接访问硬盘 | runtime.py 的 loop、dispatch;models.py 的参数定义 |
| 为什么能拆子任务? | 为子新建一份对话,复用同一个循环,主只收到简短结果 | runtime.py 的 child;coordination.py 的任务记录 |
| 怎样把资料搜出来? | 先准备两套索引;查询时找候选、合并排序、检查缺口 | stores.py 的 ingest/search;agentic.py 的 AgenticSearch |
| 两个代理会不会覆盖文件? | 同名笔记属于不同目录,修改已有文件还要核对版本 | workspace.py 的 WorkFiles |
| 报告的出处怎么来的? | 引用指向原始片段,结论指向已提交结果;程序分别检查 | runtime.py 的 validate_evidence;coordination.py 的 report |
沿着一个输入完整走一遍
问题是"比较 pgvector 和 Milvus 的部署维护"。文件先变成 Source,主代理开始时只看到问题和工具说明,不会自动收到全部原文。简单时主自己 search、读写笔记;复杂时先 plan,再派发依赖已完成的 task。子在自己的上下文看原文,只返回结构化摘要。最后主提交 SaveReport,程序从登记表取出选中的子发现,另行校验主亲自查证的发现,组装 Report 并写入新目录。
不要画错: 模型不直接连接文件系统;task 不是数据库任务表;子代理不是独立容器;save_report 不是任意路径写文件。
简历四条贡献:每条怎样讲、怎样接追问
简历上的一句话不是面试答案。每条都按"问题 -> 做法 -> 可观察结果 -> 边界"展开;面试官继续追问时,回到同一个具体例子,不要再堆名词。
贡献一:Agent Loop、工具调用、按需派发
开场回答(约 45 秒):
我用 Python 写了显式 Agent Loop。主 Agent 先可以自己 search、读原文和写笔记,模型只返回工具名和 JSON 参数,runtime 做权限和 Pydantic 校验后才真正执行。问题需要拆成独立研究面时,主先 plan,再按 task 派发;子用新 messages 跑同一个 loop,结束时只交结构化发现和引用。简单任务不派子,避免平白增加模型调用。
代码和证据落点: runtime.py 的 loop/dispatch/tools_for/child;主子工具白名单;test_simple_task_runs_on_parent_with_file_tools_and_no_children;test_child_cannot_delegate_or_publish。
追问链:
- 问:模型是不是直接执行 Python? 答: 不是。它提出
search或write_file请求;dispatch根据当前角色白名单选择处理器,参数再经具体模型校验。错误以 tool 结果回填,模型只能在剩余轮数内修正。 - 问:为什么不所有问题都拆成子代理? 答: 简单任务主自己做更短、更省请求,也保留读写能力。当前"复杂"由主模型结合提示词判断,没有声称实现了一个准确的复杂度分类器。
- 问:如果模型声称完成但没交报告呢? 答: 自由文本不能结束 loop,必须调用单独的
save_report或finish_research。到硬上限仍没有结构化结果就失败,不伪造成功。
贡献二:ES + Milvus、RRF、Rerank
开场回答(约 45 秒):
我把正式检索接到持久化 ES 和 Milvus。ES 用 IK/BM25 找术语和精确词,Milvus 用真实 Embedding 的 HNSW/COSINE 找语义相近片段;两边共享 Source ID,先做 RRF 融合名次,再对少量候选 rerank。AgenticSearch 还负责查询改写和证据是否充分的判断,所以数据库只负责候选,不直接生成答案。资料或纳入指纹的向量模型、分词配置变化会产生新的不可变快照,双库核验通过才发布 ready。
代码和证据落点: stores.py 的持久化后端和 ready manifest;retrieval.py 的 RRF;agentic.py 的 rewrite/grade;test_retrieval.py、test_stores.py;20 题小测只说明底层召回对照,不是 Agent 准确率。
追问链:
- 问:为什么不直接把 ES 分数和向量分数相加? 答: 两种分数不在同一量纲,直接相加需要额外校准。RRF 按名次融合,减少校准依赖,但会丢掉分差信息。
- 问:RRF 是 rerank 吗? 答: 不是。RRF 只综合各列表名次;rerank 才比较问题与候选正文的关系。项目把这两步分开记录。
- 问:更换 Embedding 模型但原文没变,哪一版变? 答: Source.version 不变,但索引 snapshot fingerprint 变,旧 Milvus 向量不能直接复用;要先为新 fingerprint ingest。工作笔记版本与这两者也无关。
贡献三:查询改写、证据评估、有限补查
开场回答(约 40 秒):
我没有把一次检索后生成当成完整 Agentic RAG。
search内先保留原问题并生成 1 到 2 个改写,几路并发召回后按 Source ID 做 RRF,必要时 rerank,再让结构化评估判断"这些片段是否真的能回答问题"。如果只找到相关背景、缺少问题所需数据,就按缺口生成一次新查询;仍不足就返回 gaps。改写和补查都受 search 次数、请求预算和总超时限制。
追问链:
- 问:相关资料为什么不算足够证据? 答: "资料列出监控和备份"与"谁更便宜"相关,但没有价格依据,不能推出价格结论。
sufficient是模型判断,程序只校验 ID 合法、数量和契约,不把它当逻辑蕴含证明。 - 问:为什么最多补查一次? 答: 防止换几个近义词无限搜索。若下一查询和当前相同、已经足够或没有 retry_query,就结束;硬上限是确定性边界,不是质量保证。
- 问:搜索服务挂了会退回内存吗? 答: 正式 ES/Milvus 路径不会静默降级为内存,否则报告会掩盖实际检索通道没工作。内存后端是显式 demo/对照模式。
贡献四:上下文隔离、独立工作区、版本校验
开场回答(约 50 秒):
这里的隔离不是给每个子启动进程,而是隔离四类状态。子 loop 新建 messages,不继承主的阅读过程;子 AgentState 的 seen 独立,只有自己读过的 Source 才能作为自己的新结论引用;每个 task 的 WorkFiles 根目录不同,同名 notes.md 也不会覆盖;子完成后通过 Coordinator 登记 finding_id,主收到限长结论和出处元数据,需要核验时主动读共享原文。编辑已有笔记还要带最后读取到的内容哈希,哈希变化就拒绝旧请求。
手推一个冲突: A 读 notes.md 得到 H1,随后另一轮把它改成 H2;A 还带 H1 发 edit。WorkFiles.write 重新计算当前哈希发现 H1 != H2,于是拒绝。正确恢复是重新 read、确认 old_text 仍只出现一次,再基于 H2 生成新 edit;不是把 H1 改写成 H2 后盲目重放。
追问链:
- 问:主是不是看不到子读过的原文? 答: 主可以主动
read_source共享语料;只是子不会自动把全部 messages 和 Source.text 回灌。子结论和主的 seen 是两条数据流,减少上下文冗余也防止主把子引用冒充自己的阅读凭证。 - 问:哈希是分布式锁吗? 答: 不是。它是单进程工具层的乐观并发检查,防止按旧内容覆盖新内容;没有外部进程锁、版本历史或跨机器 CAS。
- 问:这能消除幻觉吗? 答: 不能。ID 和版本保证可追溯及不覆盖,不能证明结论被原文语义支持;原文说没有数据时,模型仍可能写出一个数字。
这四条回答都能回到源码和测试;不要把"接口已实现"说成"任意真实任务都稳定成功"。真实简单任务、离线复杂流程和复杂真实验收的边界集中在 VALIDATION.md。
3. 最重要的模块:Agent 循环究竟怎么实现
源码:runtime.py 的 loop、dispatch、tools_for。
先理解一个普通函数调用
如果不接模型,你会写:
python
sources = await retriever.search("Milvus 部署", limit=2)
接入模型后,只是把"什么时候调用、用什么查询"交给模型提出。模型不能运行这行代码,只能返回名字和参数;程序收到以后才调用函数。
因此 Agent 循环的核心不是一个会自己运行的智能对象,而是:
text
模型提出动作 -> 代码检查执行 -> 返回执行结果 -> 模型决定下一步
实现可以拆成六步
- 建立只有
system与user的messages,选定当前角色的工具列表。 - 经
Budget.invoke占用额度,调用model.chat。 - 保存
assistant发出的tool_calls,检查同一回复里的调用 ID 不重复。 - 普通工具交给
dispatch,权限和参数都通过才执行。 - 把结果作为
role=tool消息回填,每条携带原tool_call_id。 - 收到单独的终止工具时,验证结果并返回;超过轮数则失败。
模型如果只是说"我已经完成了",程序会提醒它调用终止工具,而不是直接保存那段文本。
面试时这样说
我用显式循环实现 Agent。每一轮把当前对话和允许的工具发给模型,模型返回动作请求,运行时按工具名做权限检查、按 Pydantic 模型校验参数,然后调用本地函数。执行结果带原来的
tool_call_id加回messages,模型下一轮才能基于结果继续。完成必须通过结构化终止工具,不能靠一句自由文本判断。
追问一:为什么必须保留 tool_call_id
一次回复可能同时要求 search 和 task。调用 ID 是把请求与结果配对的依据,不是文档 ID。省略或混用会破坏聊天接口的工具协议,模型也无法明确哪条结果对应哪个动作。
这里有两种完全不同的 ID:
| ID | 例子 | 标识什么 |
|---|---|---|
tool_call_id |
call_001 | 某一次动作请求 |
source_id |
e5a95ea4d55ef429 | 某一份原文片段快照 |
追问二:这跟固定工作流有什么区别
真实模式中,是否 search、怎样改写 query、是否 task、何时结束,是模型根据结果选择的。代码只规定可用动作和上限。
但 demo 是固定脚本,不能证明真实模型总会做出这些决定。项目没有自己训练复杂度分类模型;是否派发由主模型结合提示词与工具结果选择。search 内有单独的 LLM 证据评估步骤,但它也是调用现成模型,不是训练了一个保证正确的判分器。
追问三:为什么不用无上限 while True
模型可能反复调用工具、一直说话、不断返回错误参数。主子默认各最多 10 轮,另外限制每代理 search 3 次。最后两轮提示收尾;达到硬上限仍没交结果就抛 StepLimit,不伪造成功。
面试前做一个检查
打开 test_runtime.py 的 test_step_limit_and_bad_final_result。自己说明:坏的最终报告为什么不是立即落盘,以及"还有机会修正"为什么不等于无限重试。
4. 工具契约与模型接口:不可信数据怎么进入程序
源码:models.py、provider.py。
先分清三道检查
JSON 能解析,不代表参数合法;参数合法,不代表当前角色有权执行。
text
HTTP 回复能转换成 Reply / ToolCall 吗?
|
工具名是否在当前角色允许列表中?
|
该工具的 arguments 能转换成 Search / Task / SaveReport 吗?
具体角色权限由 runtime 检查。provider 负责传输形状,models 负责结构定义。边界清晰,才能知道错误应该在哪里处理。
一个具体的坏输入
json
{"query":"Milvus","limit":"2","output_path":"../resume.pdf"}
它是合法 JSON,但 Search 需要整数 limit,而且不允许 output_path。Pydantic 配置 strict=True、extra="forbid",会拒绝类型错误和额外字段。
工具 Schema 由这些模型生成再发给大模型,执行前仍重新验证。Schema 是协议说明,不是已经执行过的权限校验。
模型适配器怎么写
CompatibleModel 用 HTTPX 异步发请求。chat 访问 chat/completions,将服务返回的 content、tool_calls 和 usage 转成内部 Reply。embed 访问 embeddings,按返回 index 对齐输入顺序,拒绝缺失或重复索引。
HTTP 状态先检查;成功状态里的结构如果不合法,转成 ProviderProtocolError。具体工具 arguments 的业务校验还要等 dispatch,不与 HTTP 解析混成一层。
DemoModel 则返回固定 Reply。两者有相同的 chat 接口,运行时不必知道"这次回复来自真实服务还是测试代码"。这就是这里用接口与依赖注入的实际理由。
面试时这样说
我没有把模型 JSON 直接拿来执行。提供方响应先转换成内部
Reply,运行时再检查工具权限,最后用对应的 Pydantic 模型检查参数。工具说明与执行校验共用一份数据模型,减少两边字段不一致。错误参数会作为工具错误返回,模型只能在剩余轮数内修正。
追问:HTTP 200 为什么还能失败
例如 choices 是空数组,或者 message 是 null。HTTP 层成功不意味着满足本程序的响应契约。我们用 MockTransport 专门构造这些响应,验证它们变成可识别的协议错误,而不是意外的索引异常。
追问:聊天与向量为什么独立配置
实际使用中两个服务可能不在同一端点。独立 Embedding URL/key 必须成对设置;否则就明确复用聊天配置,而不是把聊天 key 默默发给另一个地址。
连接要关闭:CLI 的 finally 调用 model.close,分别关闭聊天与独立向量客户端。超时不等于连接永远不用管。
证据:test_provider.py 的请求路由、认证隔离、关闭连接、非法配置与异常响应测试。测试用的 key 都是明示的假字符串,不是可用凭据。
5. 检索与持久化:ES、Milvus、RRF 怎么一起工作
先区分底层召回与 Agentic RAG
stores.py 负责"从两套索引里找到候选段落";agentic.py 负责"怎样换问法、挑段落、判断还缺什么"。主子 Agent 都只需申请 search,不必自己拼这些步骤。
先用问题"Milvus 比 pgvector 便宜吗"讲一遍。以下是说明流程的示例,不是保证模型每次都输出同样的查询:
- 换问法: 除原问题,还查"部署维护成本""资源需求对比",减少只靠一种措辞漏资料的可能。
- 找候选: ES 按词找,Milvus 按语义找。两者都返回片段编号,代码按编号把重复段落合并。
- 确定阅读次序: RRF 先综合各列表的名次;Rerank 再看问题与候选正文的关系,重新排先后。
- 检查能否回答: 资料只说明需要监控备份,没有实际费用。它"与费用有关",但不能证明谁更便宜。
- 决定是否补查: 评估模型提出一个新的查法时,程序最多再查一次;仍不足则带着缺口返回。
评估结果在代码里用四个字段表达。先记含义,再记英文:
| 字段 | 白话含义 | 例子 |
|---|---|---|
| ordered_ids | 按有用程度排列的片段编号 | 先看部署说明,再看选型原则 |
| sufficient | 这些资料够回答原问题吗? | 没有费用依据,所以为 false |
| missing | 还缺什么? | 同条件下的费用数据 |
| retry_query | 下一次尝试查什么? | 围绕缺少的费用依据再查,而不是重复已有查询 |
面试口述:"我把找资料和判断资料够不够分开。先用几种问法去 ES、Milvus 查,合并候选后重排,再让模型判断它们是否能回答原问题。比如问费用,但只找到部署说明,就只能说资料相关,不能说答案已经找到了。程序允许再补查一次,并限制请求数,避免一直搜索不结束。"
追问:RRF 和 rerank 有什么区别? RRF 看名次,不理解原文与问题语义;rerank 重新比较问题与候选文本。没配置专用服务时,LLM 显式承担列表排序和评估;配置 DashScope 三项后先执行专用模型,再用 LLM 判证据。不是把 RRF 改名叫 rerank。
追问:为什么不强制每题生成三条新查询? 原问题保留,加 1-2 条改写即可展示多路检索;过多相似查询会增加向量请求和重复候选。查询只在当前 search 内扩展,不污染主任务计划。
追问:充分性评估可靠吗? 它仍是模型判断。程序只能拒绝未知/重复/超量 ID 和零证据声称充分。评估不是 entailment 证明,不应把 sufficient=true 当"回答必对"。大样本独立标注仍需补充。
追问:接口怎样校验? Rewrite/Grade 用 Pydantic 严格契约,只接受指定工具一次调用;DashScope 返回的 index 必须恰好覆盖候选且不重复,score 必须是有限数。凭据独立、响应正文不写公开日志,HTTP 错误只记录状态码。
源码:stores.py。离线算法对照和 RRF 工具函数在 retrieval.py。
一句话先说清分工
ES 保存原文并做关键词 BM25,Milvus 保存 Embedding 向量并用 HNSW/COSINE 做语义检索,两边使用相同 Source ID,通过 RRF 合并排名,再把原文交给 Agent。
ES/Milvus 不决定派哪个子代理,也不直接生成答案。它们只负责数据和候选检索。
入库和查询为什么分开
ingest 负责"读取快照、批量生成文档向量、写两库、核验后发布 ready";run 负责"找到对应已入库快照、生成查询向量、检索和调研"。
同一快照再次 ingest 会复用,没有文档 Embedding 请求。重启 CLI 不丢向量,因为它们存在 Milvus 的持久化存储中。运行时的 Corpus 仍在内存,只保存本次原文与版本供引用核对,不再承担向量数据库职责。
ES 具体建了什么 mapping
每个 Source 是一个 document,_id 等于 Source.id。text 是 text 类型,写入 analyzer 为 ik_max_word,search_analyzer 为 ik_smart,similarity 为 BM25;id、path、version 是 keyword;行号是 integer。
keyword 是精确值字段,适合 ID 和过滤;text 会分词,适合全文检索。两者不要只背名称,要举例:路径不应该被拆成一堆词,而原文需要按词查找。
写入调用 bulk 并检查 errors;查询 match(text=query),只选 kind=source,按 _score 再按 id 排序。BM25 由 ES/Lucene 实现,不在 Python 手算。
公式可以作为追问补充:
text
score(D,Q) = Σ IDF(t) × tf(t,D)×(k1+1)
/ [tf(t,D)+k1×(1-b+b×len(D)/avgdl)]
tf 表示词频,IDF 表示区分度;长度归一化避免长片段天然占优。公式中的细节和默认参数由 ES/Lucene 处理,这个项目没有重新实现 BM25,也没有训练其参数。
IK 为什么两种模式
ik_max_word 倾向细粒度切分,写入更多词项;ik_smart 用较粗的方式处理查询。这样的组合是明确的配置取舍,不保证所有中文查询都最优。
插件必须与 ES 版本匹配。本机复用已有 analysis-ik。没有插件时入库会先 analyze 检查并失败,不先付费向量化后才发现分词不可用。其他环境可以显式选择 cjk/cjk 或 standard/standard,并生成新的快照。
不要把离线 demo 的"中文单字/双字 + rank-bm25"说成正式 ES 的算法。
Milvus 的集合和索引怎么设计
集合有 id 与 vector 两个字段:id 是 VARCHAR(16) 主键、关闭 auto_id;vector 是 FLOAT_VECTOR,维度来自真实 Embedding。原文主要存 ES,同一次研究的 Corpus 也保留原文。
为何不用自动整数 ID?ES 与 Milvus 要能指向同一份 Source。确定 ID 可以做去重、按主键 upsert、两库核验和最终引用。
向量索引为 HNSW,距离度量为 COSINE。文档和查询经过数量/维度/有限值/零范数检查,再归一化。测试还专门拒绝维度变化。
HNSW 要能讲到什么程度
先讲直觉:
它是一种图式近似近邻索引。用多层连接先快速靠近查询向量所在区域,再在更细的层扩展候选,所以不用由应用层每次对全部向量做线性扫描。
再讲本项目参数:
| 参数 | 本项目 | 含义与取舍 |
|---|---|---|
| M | 16 | 邻接数量,更多连接通常占更多内存 |
| efConstruction | 128 | 建图探索范围,影响构建成本与图质量 |
| 查询 ef | 64 | 搜索候选范围,通常在召回与延迟之间取舍 |
不要宣称这组值是最优。这个项目数据小,没有做大规模参数寻优,也不能把近似近邻说成保证找出所有真实最近邻。
Milvus flush 用于等待数据落盘,load_collection 使集合可查询,Strong consistency 用于核对和读取写入结果。ES refresh 则主要保证新文档对搜索可见,不是两库共同提交事务。
ES 和 Milvus 的结果怎样关联
两边都返回 Source ID。ES 分数与余弦分数不在同一量纲,所以各取前 20,再用 RRF:
text
A:ES 第 1,Milvus 第 3 -> 1/61 + 1/63
B:仅 Milvus 第 1 -> 1/61
按 ID 累加、去重,再取前 k。这里的 60 是平滑参数,没有做调优。RRF 是排名融合,不是 Cross-Encoder;它减少了分数校准需求,同时丢失了分差大小的信息。
两路异步并发,一边失败会取消并等待另一边。没有静默退回单路或内存,因此 run.json 的 es-milvus-rrf 不会掩盖某个通道根本没工作。
文件删改后,怎么避免旧向量干扰
项目采用不可变快照,而不是把所有版本塞进同一集合。由 Source ID 集合、向量服务/模型/人工 revision 的哈希、分词配置和 schema 版本计算指纹。两库命名都包含它。
新增、修改、删除文件,或换模型/分词,指纹变化,查询转向新快照;没有对应入库记录就 IndexNotReady。旧快照还在,但这次查询不会用它。更换同名模型的实际权重,需要手动增加 EMBEDDING_REVISION。
因此要准确说"版本化入库与复用",不要说成"已做逐文件增量索引":当前新快照会重新生成整批文档向量,旧版本也没有自动 GC。
两库写入如何处理部分成功
顺序是 ES bulk -> Milvus upsert/flush -> 核验两边资料身份与维度 -> 写 ES ready manifest。某一步失败就没有 ready,研究不读取半成品。
没有 ready 的同名快照可再次 ingest,按确定 ID upsert 完成;已经发布的快照只校验复用,不自动改写。读取还会核对 ES 原文、两边 ID 集合和 Milvus 维度,不只看一条成功标记。
这是"完成标记 + 核验 + 幂等重试",不是跨数据库 ACID 事务。当前约定同一快照单写者,不提供并发入库锁或在线迁移别名;旧快照积累需要另外设计清理策略。
面试时这样说
我把检索接到了现有 Docker 中的 ES 和 Milvus,入库与研究分开。ES 通过 IK 分词和 BM25 查关键词,Milvus 持久化真实向量并用 HNSW/COSINE 召回,两边以同一个 Source ID 做 RRF。为了避免版本混用,我按资料和模型配置建立不可变快照,双写核验完成后才发布 ready。重复运行能复用文档向量,资料变化则要求重新入库,不会静默读旧版本。
追问:这比内存一定更好吗
对持久化、索引管理和学习实际检索系统来说更合适,也能避免重复向量化。但有服务部署、双写一致性和存储维护成本;三篇文档的小实验不需要它才能正确运行。检索质量由分词、模型、候选和融合共同决定,不能仅凭换数据库就说更准确。
实际 20 题小测:基础 ES/Milvus+RRF 的 document recall@2=0.96875,top-1=0.9375;内存混合分别是 1.000、0.875。首位命中改善,但覆盖并非全面优于旧实现。评测没有调用 AgenticSearch 的改写、重排和评估,因此既不是完整 Agentic RAG 质量,也不能推广为生产提升比例。
6. 文件模块:为什么不是直接 read_text 就结束
主子都能读写,为什么还要分目录
例如主、子 A、子 B 都想写 notes.md。如果它们写同一个文件,后写的人可能覆盖先写的内容。现在同一个相对文件名会被放进各自的工作目录;它们都能写,但写的不是同一份文件。原始资料则始终只读,避免 Agent 改完资料再把修改内容当依据。
主子都有 read_file/write_file/edit_file。原始 corpus 始终只读;写入位于各自 reports/workspaces/<run_id>/<agent_id>/。WorkFiles 接口隐藏路径检查、大小限额、版本比较和单文件原子替换。不是"为了安全就不能写文件",而是明确允许修改哪些文件。
输入例:write_file(path="notes.md", content="初稿"),返回 hash v1;read_file(area="work", path="notes.md") 得到内容与 v1;edit_file 携带 expected_version=v1,只替换恰好一处 old_text。成功后产生 v2,旧版本修改会失败。同一进程内操作不 await,两个协程不会在检查和写入之间插入;外部进程的恶意竞态不在保证内。
面试口述:"主子 Agent 都能写研究笔记,但各有自己的目录,两个 notes.md 不会互相覆盖。改已有文件时还要带上它读到的版本,如果内容已经变了,程序拒绝这次修改。写入先完成临时文件,再替换目标文件。原始资料不让模型改,最终报告也统一由程序保存。"
追问术语时再补:版本检查常被称为乐观并发控制;这里用内容哈希表示版本。不要先报这个名字,却说不出它拒绝的正是"拿旧内容修改新文件"这种情况。
追问范围:只支持 md/txt/json;每文件 12000 字符/48000 字节,每代理 8 个文件/96000 字节;拒绝父目录、盘符、绝对路径、隐藏文件和符号链接。不是容器沙箱,不允许 arbitrary shell,不修改检索语料来制造"证据"。
源码:workspace.py 的 Corpus、Source、contained。
要解决的真实问题
原文可能变,资料可能太大,路径可能越界,结论以后还要核对。仅仅把字符串读出来不够。
实现顺序是:验证根目录 -> 遍历允许的文件 -> 限量读取字节 -> UTF-8 解码 -> 文件哈希 -> 按完整行分块 -> 保存 Source。
单文件只读上限加一字节即可判断超限,不先把一个无限大的文件全部读入。文件数、总字节和片段数也有上限。
三种信息各管一件事
| 信息 | 作用 | 不能替代什么 |
|---|---|---|
| 路径与行号 | 定位依据在哪里 | 不能说明文件后来是否变化 |
文件 version 哈希 |
标识文件内容版本 | 不保存正文,也不加密正文 |
Source 中的 text |
保留当时的内容 | 不代表这段内容一定支持结论 |
片段 ID 与路径、版本、行号和正文一起关联。任务内 read_source 只取内存快照,即使磁盘文件后来修改,也不会悄悄换依据。
面试时这样说
我在任务开始时保存一份资料内容,并记录文件名、行号和版本。后续既能按片段编号读取,也能通过 read_file 读取指定文件的行范围,读的仍是这次保存的内容,不会中途换成磁盘新版本。最终报告保留引用原文,所以以后文件改了,也能核对当时的依据。
追问:为什么按行切,没有 overlap
按行切容易精确保留来源位置,实现也简单。代价是跨块上下文可能断开。加入按标题/段落切分和 overlap 可以改善连续性,但要保持来源映射并用题集验证,不能说当前 1800 字符是最佳选择。
追问:为什么不直接支持 PDF
项目明确限制 UTF-8 Markdown/TXT。PDF 文本层、扫描 OCR、表格顺序与页码引用是另外一套解析问题。支持扩展名不等于解析可靠,当前没有把 PDF 算成已实现能力。
追问:这算文件沙箱吗
不是 OS 沙箱。程序做了 resolve 后的目录包含检查、跳过隐藏文件和链接、只允许受控 Source ID。它减少工具层权限面,但不是对恶意本地进程、容器逃逸或多租户场景的全面隔离。
证据:test_workspace.py 的快照稳定、路径越界、行号保存和超限拒绝测试。
7. 子代理模块:不是多写几个角色提示词
源码:runtime.py 的 child、tools_for、loop。
先说清"子"体现在哪里
先不要给面试官讲字段。用一个具体分工说明:主决定"让 A 查 pgvector,让 B 查 Milvus"。A 只收到自己要查的问题;它调用同一套工具循环,却有一份新对话,不需要知道主之前聊过什么。A 完成后把结论、出处、缺口交回来,而不是把每次搜索和读文件的过程全部交回来。
主先 plan 登记 task_id/question/depends_on,再 task(task_id)。child 创建新状态并复用 loop,只接收子问题与已完成依赖的精简结果,不带父对话历史。简单问题主直接 search/read/write,不强制委派。
但它共享 Corpus、Retriever、模型连接和 Budget。所谓隔离,准确说是上下文独立和工具权限区分,不是进程隔离。
一次 child 的完整生命周期
- 校验已登记任务、依赖是否完成,检查累计任务数并预留名额。
- 创建
parent=False的AgentState,seen与searches都是新的。 - 等待 Semaphore 执行名额,增加活跃数与峰值统计。
- 用新 messages 研究,可检索和读写自己的工作文件,但不能 plan/task/save_report。
- 检查终止结果,保证引用来自它自己的
seen。 - 成功后为每条发现登记 finding_id,只回传限长 findings/gaps 和文件元数据,不回传原文,不更新父 seen。
- 在
finally减少活跃数,退出 semaphore 释放并发名额。
父并非无条件相信摘要:原始证据保存在程序侧,父可主动 read_source 核验,也可委派核验任务。默认不自动回灌全文,避免每派一个子任务就重新累积它读过的全部资料。摘要本身仍可能误导,因此只称"减少上下文污染",不称"彻底防住"。
面试时这样说
我没有让主 Agent 变成只能派活。小问题它自己查、自己写;需要分开研究时才建子任务。比如 A 查 pgvector、B 查 Milvus 可以一起做,但核验两份结论必须等它们完成。每个子有自己的对话和笔记,最后只交结果和出处。主想核对哪条,再主动读那段原文,不必默认接收全部过程。
追问:谁判断简单还是复杂?plan 会自动执行吗?
当前由主模型判断,提示词建议简单直做、独立研究才拆分,没有写死"字数大于多少就派发"。plan 只是把工作及前后关系登记下来,task 才请求启动某项工作。代码检查任务是否存在、前置任务是否成功、还有没有预算;它不保证模型的拆分一定合理。
所以应该说"支持按需委派",不说"实现了准确的复杂度分类"。确定性测试证明主可以零派发完成文件任务,不能证明任意真实问题都被完美分类。
追问:为什么不让所有代理写同一个目录
共享目录会让两个模型读到旧版本后互相覆盖。这里每个代理都能写,但写自己的工作区;主最终汇总报告。需要交接时先交换经过校验的结论和文件元数据,不把私有文件路径当作其他代理自动可读的权限。原始语料只读。
代码有两层限制:不给 child 展示 task/save_report;dispatch 仍根据角色重新拒绝伪造工具。不存在"虽然说明里没写,但模型猜到函数名就能执行"。
追问:什么时候不该拆
简单问题由主代理直接查询更省。子任务必须能独立研究;依赖前一个结论的任务要等结果。并发会增加模型请求和汇总工作,也可能重复查同一段资料,不天然提升质量。
追问:子代理失败了,父代理一定失败吗
局部协议错误或 child 请求超时可以变成失败结果,让父代理用已有证据输出 partial;全局 BudgetExceeded 向上传播,终止研究。达到总任务数限制会拒绝新派发并登记失败,不偷偷扩容。
证据:test_coordination.py 包含主独立完成、子原文不回传、伪造引用拒绝、依赖失败与排队取消测试;test_workfiles.py 覆盖工作区隔离、路径与旧版本编辑。
8. 预算、并发和取消:最容易被追问的工程部分
源码:runtime.py 的 Budget、child、loop、run。
先把三个数字拆开讲
- 并发 2:此刻最多两个
child在执行研究。 - 任务数 4:一次研究累计最多派发四个
child。 - 请求数 48:所有代理的聊天、改写、评估、Embedding、专用 rerank 共用一个上限。
这三个数字互不替代。并发 1 也能串行调用一万次;任务数少也可能每个任务里请求很多次。
为什么共享计数没有在 await 中间被抢
实现按下面的顺序做,下面省略了请求 timeout 的包裹:
python
if self.calls >= self.limits.max_calls:
raise BudgetExceeded("Model call budget exhausted")
self.calls += 1
return await function()
检查与加一之间没有 await。在单事件循环中,这一小段不会让出执行权。第二个协程开始检查时,看到的是已经增加过的计数。
如果反过来"请求结束后再加一",两个 child 都可能看到旧额度,于是同时超发。当前失败请求也算一次,不做返还,计数表达的是发起尝试。
为什么 Embedding 也必须走这个计数
只给 chat 计数是不完整的。建文档索引、每次向量查询都要调用外部服务。在 ResearchRun 初始化时,对传入的 embed 函数再包一层 metered_embed,让它调用同一个 Budget.invoke。
这也是"调用函数作为参数"有价值的地方:Retriever 不用知道预算对象或 API key,只会调用一个异步 embed 函数,计数集中在运行时。
gather 的坑怎么回答
简单说"我用了 asyncio.gather 实现并发"不够。一个任务异常,其他兄弟任务未必已经停止。
代码先 create_task 留住任务对象,再 gather 等待;无论成功还是失败都进入 finally,对未完成的 task 调用 cancel,再 gather(..., return_exceptions=True) 等待退出。第二次 await 是为了完成清理,不是忽略业务错误后当成功。
Python 新版本也可以评估 TaskGroup,但这份实现使用显式任务清理,没有声称已经采用结构化并发 API。
面试时这样说
我对并发、总任务数、总请求数分别设限制。所有
child和向量请求共用Budget,在单事件循环里先检查并递增,再await外部调用,避免两个协程同时用旧额度。并发批次失败时,我不仅给兄弟任务发cancel,还等待它们清理结束。单请求与整段研究各有timeout,失败会留下明确状态和诊断。
追问一:这是线程安全的吗
只针对这个单进程、单事件循环的协作并发。多线程、多进程、多实例不能靠这段普通整数操作保证额度,需要锁、外部原子计数或数据库事务。不要把单机保证扩大成分布式保证。
追问二:超时可以停止计费吗
只能取消本地等待,不能保证远端已经停止生成或停止计费。请求数也不是费用预算,chat usage 是接口返回的统计,没有总金额预留,也没有计入完整 Embedding 费用。
追问三:时间限制包住了哪些工作
研究区间 timeout 包住正式后端的 prepare 核验与 Agent 循环;单独 ingest 另受预算与总时间限制。内存对照模式才在 prepare 生成文档向量。CLI 先前的同步文件加载,以及最后的文件发布不在里面。请求 timeout 包住单次聊天或向量调用;HTTP 客户端本身也有网络超时。
追问四:为什么不自动重试
当前没有网络错误的自动重试;证据不足时则允许一次换查询补检索,两者不同。HTTP 402/协议错误不该无限重试。临时网络错误若要重试,需要分类、退避和次数限制,每次仍消耗预算。
证据:test_shared_budget_never_overshoots、test_timeout_cancels_children、test_tool_budget_and_concurrency_one。要能打开测试指出断言,而不是只背测试名字。
9. 引用与报告模块:怎样表达"可靠",而不夸大
源码:runtime.py 的 validate_evidence、run、render,以及 workspace.py 的 ArtifactWriter。
一个发现至少有两个字段
json
{
"statement": "资料指出复用 PostgreSQL 可以减少额外运维组件。",
"source_ids": ["e5a95ea4d55ef429"]
}
Pydantic 要求 finding 至少带一个引用。运行时再检查引用是否属于当前 AgentState.seen。整体不能 findings 和 gaps 都为空,避免提交空报告。
search/read 把正文交给某个 Agent 后,程序把片段编号记在这个 Agent 的已读清单 seen 中。list_documents 只给文件名等信息,所以不记入已读。
另一个编号 finding_id 指向的是"子已经交付的一条结论",不是原文。例如子 A 提交"已有 PostgreSQL 时可复用它保存向量",附上出处;代码检查后把这条结果记成 pg:f1。主可以选择 pg:f1,由程序原样取出这句话及出处。
但主不能把它改成"pgvector 的性能一定更好",再把子引用编号照抄上去。若主想提出新的判断,必须先读取有关原文,再提交自己的结论。这就是"主的已读清单"与"子交付结果列表"分开维护的原因。它防止一种来源误用,不保证看过原文以后推理就一定正确。
必须会讲的反例
原文说"没有性能数据",模型却写"性能提升 50%",再引用这段真实原文。我的 ID 检查可能通过,但这个结论仍是错的。
这个反例说明你理解边界:存在性与可追溯性,不等于语义支持。不能因为用了 RAG、输出了引用就说消除了幻觉。
为什么有 partial
有报告,但报告声明了资料缺口,或者发生过 child 失败,就是 partial。没有这些情况才是 completed。状态只描述执行协议,不认证内容真假。
全局预算耗尽、总超时、没有得到有效报告的其他失败,会保存诊断,不编造一份报告充数。局部 child 失败如果父代理还能提交报告,则可以 partial。
为什么写入集中在最后
模型提交 SaveReport,程序展开成 Report。run 负责编号、来源表、状态;ArtifactWriter 接收五种固定文件名,包括 tasks.json。工作文件另有私有目录,不与最终报告混在一起。输入与输出树互不包含。
单文件用临时文件 + os.replace,但五个产物不是一个事务。磁盘故障可能留下部分文件,没有断点恢复提交标记,不称事务性发布。
面试时这样说
我会为结论保留文件出处、行号和当时的原文。主可以选用子已经提交的结论;要补自己的判断,就先读相关资料,不能只拿一个引用编号当依据。最后由 Python 统一写报告。不过,读过资料不等于理解正确,所以我强调来源可以复查,而不是声称已经消除了幻觉。
追问:report.md 能直接当安全 HTML 吗
不能。代码转义 HTML 字符,但它仍是模型内容,不能当作已经过完整安全清洗的 Markdown。查看器应按不可信文本处理,不自动执行内容。
追问:资料中的提示注入怎么防
系统规则明确原文是数据;实际动作仍被工具白名单、参数模型和文件范围限制。没有 Shell 是一个重要的权限收缩。但模型仍可能被诱导写出错误结论,因此不是"提示注入完全解决"。
10. CLI、测试和评测:怎么证明自己理解了整个项目
源码:cli.py、evaluation.py、tests。
CLI 负责装配,不负责研究
main 解析命令,asyncio.run 驱动 execute。execute 创建 Corpus、Limits,选择 DemoModel 或 CompatibleModel,调用 ResearchRun.run,再打印状态、目录和统计。
completed/partial 返回 0,其余运行失败状态返回 1。真实客户端在 finally 关闭。配置错误会输出错误类别,不打印可能包含凭据或资料正文的异常细节。
面试时不必逐行背入口,重点说:
我把参数与资源生命周期放在 CLI,把研究逻辑放在
ResearchRun。运行时接受模型接口,所以可以用同一条链路接真实服务或测试 fixture,测试不需要真正调用 API。
四层证据分别证明什么
| 层次 | 做了什么 | 不应该声称什么 |
|---|---|---|
| 单元与行为测试 | 临时目录、固定模型、合成向量,测权限与执行规则 | 大模型准确率达到某个数值 |
| HTTP 模拟测试 | 检查路由、认证、结构解析与异常 | 所有外部服务可用 |
| 真实端到端样例 | 真实聊天、Embedding、子任务与报告 | 生产稳定性、平均延迟 |
| 20 题检索小测 | 同题比较 BM25 与混合检索的资料覆盖 | 答案真实性评测、泛化准确率 |
具体测试数与外部服务验收看 VALIDATION。真实简单任务与一例真实复杂任务已有报告,历史预算耗尽与 HTTP 402 仍保留。复杂成功样例用了 62 次请求、显式上限 80,不能移作默认 48 次预算足够的证明,也不能把单次成功说成稳定性或泛化质量。跨平台 CI 是否通过应看对应提交记录。
评测模块怎么实现
evaluation 从 JSON 读取 Case:query 和 expected_documents。标注先存在文件中,不传给检索器。每题 search(limit=2),收集前两块的 source_id 和对应去重文档路径。
有答案题计算:
text
本题 document recall@2 =
前两个 chunk 所覆盖的正确文档数 / 这道题要求的文档数
再对有答案题求平均。top-1 命中率看第一个结果是否属于预期文档。没有答案的题不进入 recall 分母,单独统计空结果率。没有样本的指标返回 null,不假装 100%。
这次结果应该怎么说
我做过三份短资料、20 道题的检索回归集。基础 ES/Milvus+RRF 的 document recall@2 是 0.96875、top-1 是 0.9375;旧内存混合分别是 1.000 和 0.875。这是底层检索器的历史对照,不包含新加入的查询改写、专用 rerank 和 LLM 评估。四道无答案题仍返回候选,说明相关不等于可回答。不能把这些数值说成整个 Agent 的准确率。
这比只报一个"100%"更有说服力,因为你说明了指标定义、对照组与暴露的问题。
追问:只有 3 个 chunk,测这个有意义吗
有教学和回归价值:可以检查跨文档覆盖、无答案误命中等基础召回回归。题集中有人工写好的英文改述,但这不验证运行时 QueryRewrite。样本太小,top-2 很容易覆盖大部分资料,不能证明真实规模的检索质量。下一步需要更多文档、独立标注、更多干扰项和划分好的测试集。
11. 两个真实可讲的排错与改进案例
这些是项目开发与受控验收中的问题,不是生产事故。口述时不要杜撰"线上用户投诉""我值班定位"等情节。
案例 A:Embedding 超时,却不是向量算法坏了
现象: 既有向量接口连接超时,无法完成混合检索。单元测试能通过,因为模拟网络不会复现机器上的代理路由。
排查顺序:
- 用固定短文本单独请求 Embedding,先把 Agent 调度排除。
- 默认路径失败;显式走系统代理也 ConnectTimeout。
- 系统没有常见的代理环境变量,但 Windows 代理解析仍能返回代理设置。
- 保持端点、密钥、模型和输入不变,显式
trust_env=False直连,得到 HTTP 200、1024 维向量。 - 补代理开关和独立向量配置,再通过真实 CLI 跑完整流程。
怎么说:
我先把失败缩小到一个最小向量请求,再只改变网络路由。最后发现 HTTPX 在 Windows 上会读取系统代理,不是看不到
HTTPS_PROXY就代表没代理。修复不是关证书验证或换模型,而是提供明确的环境信任开关;用户选择直连时仍保留 TLS 校验。然后用实际 CLI 验证完整链路,而不是只证明一个临时脚本能调用接口。
不要说"所有 Embedding 超时都是代理问题"。这是这台机器上的观察与对照结果。其他情形还可能是 DNS、配额、接口路径、模型名或服务端故障。
案例 B:上下文隔离不能靠取消主代理权限
问题: 自动把子引用全文回传,会让主上下文随着任务数累积;反过来完全禁止主检索与读写,又会让简单任务被迫派发,增加成本。
处理: 主保留基础工具,按需委派;子返回限长结论和引用 ID,原文由程序持有。主通过 finding_id 选取登记结论,需核对时主动回读。主自身证据 seen 与子结果登记表分开校验,避免摘要成为伪造新陈述的凭据。
怎么说:
我没有把主 Agent 限制成只能调度。简单任务它直接读写检索,复杂任务才分工。隔离的是子任务的探索历史,默认只回传结论与证据句柄,不是剥夺主的核验能力。程序保留原文和来源链,主想验证时再取需要的片段。
边界:摘要仍可能遗漏条件,主动回读也不能证明推理必然正确;这些设计减少上下文冗余与来源误用,不是消灭幻觉。
12. 常见架构取舍,别回答成"以后全上"
用了哪些合理的设计模式
先讲遇到了什么问题,再说设计叫什么,顺序不要反:
| 实际问题 | 本项目怎样解决 | 可以对应的设计名称 |
|---|---|---|
| 多方面研究要分工,但小问题没必要拆 | 主能直接干活,也能交给独立子任务,最后汇总 | Supervisor/Worker(主从分工) |
| 测试不能每次都花钱调模型,正式检索与离线演示也不同 | loop 只调用统一接口,传入真实模型或 DemoModel、正式或内存检索器 | 依赖注入、策略与适配器 |
| 模型可能让核验任务过早启动,或说已完成却没交结果 | Coordinator 用待执行、执行中、完成、失败这些状态记录任务,代码检查转换条件 | 显式状态机 |
| 模型按旧笔记发来修改请求,可能覆盖后来内容 | 编辑时比较请求带的版本与当前文件版本,不同就拒绝 | 乐观并发控制 |
Pydantic 负责把工具输入写成明确的数据格式并检查;DashScopeReranker 则把专用服务协议封装起来。这些代码各有具体职责,不需要把所有类都包装成一个设计模式。
设计模式追问:名称、替换点和不适用点
面试官通常不是想听四个模式名,而是想知道你是否因为真实变化才这样拆。可以用下面的固定句式回答:"问题是什么;哪个对象隔离了变化;替换时谁不用改;我没有承诺什么。"
1. 依赖注入、策略、适配器不是一回事
python
# 简化 CLI 的装配方式,省略配置读取和关闭连接。
def factory(corpus, metered_embed):
return PersistentRetriever(corpus, metered_embed, identity, settings)
run = ResearchRun(corpus, output, model=model, embed=model.embed, retriever_factory=factory)
这里 model 是 CLI 创建的 CompatibleModel,identity/settings 也由 CLI 读取并计算。离线时可传 DemoModel 和内存后端 Retriever。当前 CLI factory 使用包裹了预算的 metered_embed,避免正式路径绕过计数;接口本身并不能阻止开发者另写一个违规 factory,替换实现也要遵守这一约定。
- 依赖注入:
ResearchRun接收model、embed、retriever_factory,因此测试可以传 fixture,不必花真实调用费用。它回答"依赖从哪里来"。 - 策略替换:
Retriever与内存对照后端都履行prepare/search约定,查询方不关心具体索引算法。它回答"同一职责有哪种实现"。当前没有运行时自动熔断或热切换。 - 适配器:
CompatibleModel把外部 HTTPchoices/tool_calls/usage变成内部Reply/ToolCall;专用 reranker 也把自己的响应变成来源 ID 列表。它回答"外部协议怎样接入"。
如果面试官问"这三个能不能统称抽象",可以答:它们都减少耦合,但关注点不同;把它们混成一个词会说不清替换点。
2. Supervisor/Worker 是架构分工,不是"复制 Agent"
主代理仍有 search/read/write 能力,必要时才委派;子收到新问题和已完成依赖摘要,不能再 task 或 save_report。这个设计解决的是上下文和职责边界,不是安全进程隔离。共享的是 Corpus、检索器、模型连接(model)、Budget;不共享完整 messages、seen 和工作目录。
常见追问是"为什么不让子把全文都返回":因为全文会按任务数线性堆进主上下文;只回传限长 finding/gap 和证据句柄,主在需要时再读原文。代价是主必须主动核验,摘要可能遗漏条件,且多任务增加调用成本。
3. Coordinator 是显式状态约束,不是自然语言调度
pending -> running -> completed 是正常路径;异常路径是 running -> failed,依赖失败会让尚未开始的后继记录 dependency_failed。start() 检查前置是否完成并拒绝过早调用,finish() 登记 finding,report() 拒绝仍 pending/running 的计划。已完成任务不能改回 pending,补查要使用新 ID。
因此更准确的说法是"Coordinator 里有显式状态机",而不是"我实现了 GoF State 模式":状态数量不多,条件集中在一个类里,没有为每个状态创建对象。它也不是持久化工作流引擎,tasks.json 只是输出快照。
4. 版本校验是乐观并发技术,WorkFiles 是规则封装
WorkFiles 把路径包含检查、扩展名、大小上限、expected_version 和临时文件替换放到一个边界内;Agent 不需要自己拼物理路径。expected_version 不匹配就拒绝,成功后返回新的内容哈希。
这通常可以称作乐观并发控制 ,因为写入前不加锁,假设冲突少,发现旧版本时再失败。它不是数据库事务、分布式锁或多版本历史。WorkFiles 的价值是让调用方只需 read/write/edit,路径、版本和大小限制集中在模块内维护,不必另造模式名称。
5. 哪些东西不要硬套模式
工具请求的形状像 Command,但本项目没有单独的 Command 类、撤销队列或持久化命令日志,不应为了好听就说实现了 Command Pattern。Pydantic 是契约与校验库,不是自动的领域状态机;RRF 是检索算法,不是设计模式。能指出"不适用点",通常比再报一个名词更能证明你真的读过实现。
一段可以直接练习的综合回答:
我没有先选模式再套代码。主从分工解决多 Agent 的上下文边界;模型和检索器通过依赖注入接入,策略让离线和持久化后端能替换,适配器把外部模型协议转成内部 Reply。Coordinator 用集中状态检查阻止依赖未完成的任务,WorkFiles 则把路径与内容哈希规则封装起来。这里的哈希只是单进程乐观并发检查,不是分布式锁;任务状态也不是可恢复的工作流引擎。
设计模式面试追问的四层回答
- 白话层: "小任务主自己做,独立研究面才交给子;子不把全部过程塞回来。"
- 实现层: "
child新建AgentState和 messages,tools_for(False)去掉 plan/task/save_report,Coordinator.finish登记 finding。" - 工程层: "共享 Budget 与 Semaphore 做限额,依赖由
start检查,WorkFiles 用内容哈希拒绝旧编辑。" - 边界层: "不是进程沙箱、分布式锁或恢复型队列;语义正确性仍需更大评测和人工核对。"
被追问时按层递进,不要第一句就念完整代码。每层都应能指回理解文档中的状态变化例子。
不要说"我用了设计模式所以可扩展"。应先讲具体变化:换检索器不用改 Agent 循环;子返回格式改变只需调整契约与登记模块;文件路径和版本规则集中在 WorkFiles。接口让调用方少操心才有意义。
为什么选 Python,没照搬原学习代码
检索、文本处理、模型接口与异步调度可以在同一个包里直接组合。项目用显式循环理解执行过程,避免跨两个运行时传状态。不是说 TypeScript 不能实现,而是为这个小工具减少额外复杂度。
为什么不用 LangGraph
当前状态较少,没有持久化检查点、人工审批和长任务恢复。显式 loop 足以把消息、工具回填、权限和取消看清楚。将来真需要这些状态管理能力,再评估框架,而不是为了简历列名词。
为什么用了 ES/Milvus,却没再加任务数据库和队列
ES/Milvus 解决资料和向量持久化,不等于 Agent 任务也可恢复。当前 CLI 在一个进程里等待任务,尚无持久消息队列和中断续跑。把两种持久化区分清楚,比继续堆组件更重要。
数据规模仍限制为 400 个片段,不宣称百万级压测。已有存储服务让索引复用和真实召回更容易演示,但多用户场景还需要鉴权、隔离和任务状态管理。
为什么没有保留通用 Shell
原 Python 学习脚本具备 Shell 执行能力,而研究资料只需要读与检索。黑名单拦几个危险命令很容易漏掉绕过方式,所以这里直接不提供 Shell,而不是继续扩充危险词列表。
最值得优先改进什么
结合实际暴露的问题回答,而不是先报中间件:
- 扩充独立标注题集,尤其是无答案题与结论是否被证据支持。
- 在限长结果和原文不自动回灌的基础上,精确限制上下文 token;当前 context_chars 只是字符观测,不是 token 预算。
- 在已实现的快照复用上增加逐片段向量缓存,减少新快照全量向量化。
- 给旧快照增加保留/清理策略,再做受控增量更新与并发入库协调。
- 真要服务多用户时,先补鉴权、上传隔离、任务持久化与恢复,再做 Web 页面。
这些是后续设计选择,不是已实现功能。面试介绍完成的项目时不必一直强调"首版",但被问到边界时必须答清楚。
13. 五分钟现场演示,按这个顺序
第一步:先声明离线演示的性质
这里我先用确定性模型演示执行链路,不需要密钥。它验证程序行为,不代表真实模型每次都按这个顺序决策。
powershell
uv run docresearch demo
打开这次输出目录中的 report.md,再看 sources.json。指出一条结论的来源位置与原文,不要只展示一个漂亮的最终文本。
第二步:让中间过程可见
powershell
uv run python -X utf8 examples/walkthrough.py
只选三个关键输出讲:Source 的原文/版本、工具参数拒绝、child_start 与 finalized。二维向量输出明确是教学数据,不是服务返回值。
第三步:演示失败路径
powershell
uv run docresearch demo --max-calls 1
预期 exit code 1、budget_exhausted,新目录只有诊断 JSON,没有正式报告。说明额度在真正请求前占用,不把执行失败包装成成功。
第四步:展示持久化复用和真实运行证据
已配置服务和样例模型时,先运行两次 uv run docresearch ingest,观察第二次 reused=true、embedding_requests=0。关闭 CLI 再打开仍复用数据。未配服务时直接查看已保存证据,不临时暴露私人配置。
先打开主直接完成的真实报告与统计:0 个子任务,主自己检索、写笔记、读回、编辑并提交报告;partial 表示保留了资料缺口。这份证据适合说明"主没有失去读写能力"。
再展示复杂任务报告与任务表:两个研究者并行完成,核验任务 depends_on 指向二者,三个任务均 completed,报告因资料缺口为 partial。然后展示历史预算失败,说明复杂任务的调用开销仍需控制。具体数字集中在 VALIDATION.md,演示时不必把所有 usage 念一遍。
面试官若追问"核验真的发现过问题吗",可以这样说:
有一次研究者把自己 notes.md 的版本号写成研究发现,还引用了介绍 Milvus 的资料。来源 ID 的确存在,但资料不能证明笔记版本。核验子任务指出了这个问题,主没有选用那条发现。笔记版本应看程序返回的文件元数据,不应拿技术文章作证。这次暴露了来源有效和结论被支持是两回事;我不能说加了核验 Agent 就能消除幻觉。
若追问"既然成功,为什么还说预算有问题",答:
这次显式上限 80,实际用了 62 次,默认是 48。核验阶段多次没有及时结束,还遇到一次结果校验拒绝后修正,因此请求较多。我保留了原始轨迹,没有把调高上限当作优化。下一步应先分析重复输出与终止失败,再做相同输入的预算对照;目前没有默认配置足够或平均性能的证据。
不必临场使用私人材料或展示密钥。真实在线演示要先确认网络、授权和额度。
14. 按面试官追问深度,逐层回答
以"怎么实现子代理"为例,不要一开口塞十个名词:
第一层,先让人听懂:
主代理把问题交给新上下文的子代理,子读原始资料并读写自己的笔记,返回限长结论与证据 ID。
第二层,对方问怎么做,再给函数和状态:
task分支进入child,创建parent=False的AgentState,再复用loop。loop为它新建messages;tools_for给它更小的工具集合。
第三层,问工程问题,再谈限制:
child共享Budget和检索器,Semaphore 限并发,max_tasks限累计数量;结果要检查seen;最后在finally清理活跃计数。
第四层,问边界,再谈取舍:
上下文独立不是安全沙箱;多代理也不是一定更准。我们只拆独立研究面,不实现递归派发或分布式恢复。
其他模块也用这个顺序:一句白话 -> 具体数据与实现 -> 异常处理 -> 取舍。这样不会让答案听起来像背 README。
15. 十个不看文档的自测题
先自己用两三句话回答,再看检查点。卡住的地方回对应模块,不要直接背整篇。
| 问题 | 回答里必须有的检查点 |
|---|---|
模型返回 search 后,谁真正查文件? |
Python dispatch 调用 Retriever,不是模型直接访问硬盘 |
arguments 是什么? |
JSON 字符串,经具体 Pydantic 模型检查后才执行 |
tool_call_id 与 source_id 有什么区别? |
动作配对与原文身份是两件事 |
为什么 list_documents 不代表已读资料? |
只返回元信息,没有正文,不登记全部 seen |
child 与 parent 共享什么? |
共享 Corpus/检索器/provider/Budget,不共享完整 messages/seen |
Semaphore 与 Budget 为什么都要? |
同时执行数不等于累计外部请求数 |
| 为什么不能请求结束后才计数? | 并发请求可能同时看见旧额度,先预留再 await |
| 真实引用能否支持假结论? | 能,ID 校验不是语义蕴含证明,要给一个反例 |
| RRF 为什么不用原始分数相加? | 两路尺度不同,按名次融合,同时会损失分差信息 |
| recall@2=1.0 可以写准确率 100% 吗? | 不行,检索文档覆盖与回答正确性不同,样例规模也小 |
完成标准不是把名词复述出来,而是你能运行对应命令、打开对应函数、拿一个具体输入解释它如何变成输出。做到这一点,这个小项目就能成为你真正讲得清楚的项目,而不是简历上的一组关键词。