DocResearch 项目面试:把每个模块讲明白,而不是背术语

DocResearch 项目面试:把每个模块讲明白,而不是背术语

这篇文章默认你已经运行过 demo,并大致理解 项目理解文档 中的例子。面试时不需要把本文一次讲完。你的目标是:别人随便指一个模块,你都能说明它为什么存在、输入输出是什么、核心代码怎么工作、还有什么做不到。

每个重点模块都按"先理解 -> 看实现 -> 面试表达 -> 接住追问"的顺序写。口述段落是表达参考,不是要你背成自己没有理解的经历。

不要从头背术语。 先用第 1 节讲清"用户交给我什么、程序返回什么",再练第 3、5、7、9 节的四个核心问题:模型怎么执行工具,资料怎么找到,为什么拆子任务,结论怎么带出处。接口字段、数据库参数和异常细节用于接追问,不要全部塞进开场。

面试准备可直接进入 简历四条贡献、上下文与文件隔离、设计模式追问。遇到不会解释的字段,再回对应模块学习;第 13 节给出现场演示路线,第 15 节用于不看答案自测。

每个模块按三层准备

  1. 先讲一个动作: 例如"主让 A 查 pgvector、B 查 Milvus,拿到结果再汇总"。
  2. 再讲怎么实现: 例如"每个子新建一份对话列表,复用同一个 loop 函数"。
  3. 最后讲取舍: 例如"简单问题不拆,避免多出派发和汇总调用"。

如果第一层还说不清,不必先背 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。

追问链:

  1. 问:模型是不是直接执行 Python? 答: 不是。它提出 search 或 write_file 请求;dispatch 根据当前角色白名单选择处理器,参数再经具体模型校验。错误以 tool 结果回填,模型只能在剩余轮数内修正。
  2. 问:为什么不所有问题都拆成子代理? 答: 简单任务主自己做更短、更省请求,也保留读写能力。当前"复杂"由主模型结合提示词判断,没有声称实现了一个准确的复杂度分类器。
  3. 问:如果模型声称完成但没交报告呢? 答: 自由文本不能结束 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 准确率。

追问链:

  1. 问:为什么不直接把 ES 分数和向量分数相加? 答: 两种分数不在同一量纲,直接相加需要额外校准。RRF 按名次融合,减少校准依赖,但会丢掉分差信息。
  2. 问:RRF 是 rerank 吗? 答: 不是。RRF 只综合各列表名次;rerank 才比较问题与候选正文的关系。项目把这两步分开记录。
  3. 问:更换 Embedding 模型但原文没变,哪一版变? 答: Source.version 不变,但索引 snapshot fingerprint 变,旧 Milvus 向量不能直接复用;要先为新 fingerprint ingest。工作笔记版本与这两者也无关。

贡献三:查询改写、证据评估、有限补查

开场回答(约 40 秒):

我没有把一次检索后生成当成完整 Agentic RAG。search 内先保留原问题并生成 1 到 2 个改写,几路并发召回后按 Source ID 做 RRF,必要时 rerank,再让结构化评估判断"这些片段是否真的能回答问题"。如果只找到相关背景、缺少问题所需数据,就按缺口生成一次新查询;仍不足就返回 gaps。改写和补查都受 search 次数、请求预算和总超时限制。

追问链:

  1. 问:相关资料为什么不算足够证据? 答: "资料列出监控和备份"与"谁更便宜"相关,但没有价格依据,不能推出价格结论。sufficient 是模型判断,程序只校验 ID 合法、数量和契约,不把它当逻辑蕴含证明。
  2. 问:为什么最多补查一次? 答: 防止换几个近义词无限搜索。若下一查询和当前相同、已经足够或没有 retry_query,就结束;硬上限是确定性边界,不是质量保证。
  3. 问:搜索服务挂了会退回内存吗? 答: 正式 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 后盲目重放。

追问链:

  1. 问:主是不是看不到子读过的原文? 答: 主可以主动 read_source 共享语料;只是子不会自动把全部 messages 和 Source.text 回灌。子结论和主的 seen 是两条数据流,减少上下文冗余也防止主把子引用冒充自己的阅读凭证。
  2. 问:哈希是分布式锁吗? 答: 不是。它是单进程工具层的乐观并发检查,防止按旧内容覆盖新内容;没有外部进程锁、版本历史或跨机器 CAS。
  3. 问:这能消除幻觉吗? 答: 不能。ID 和版本保证可追溯及不覆盖,不能证明结论被原文语义支持;原文说没有数据时,模型仍可能写出一个数字。

这四条回答都能回到源码和测试;不要把"接口已实现"说成"任意真实任务都稳定成功"。真实简单任务、离线复杂流程和复杂真实验收的边界集中在 VALIDATION.md。

3. 最重要的模块:Agent 循环究竟怎么实现

源码:runtime.py 的 loop、dispatch、tools_for。

先理解一个普通函数调用

如果不接模型,你会写:

python 复制代码
sources = await retriever.search("Milvus 部署", limit=2)

接入模型后,只是把"什么时候调用、用什么查询"交给模型提出。模型不能运行这行代码,只能返回名字和参数;程序收到以后才调用函数。

因此 Agent 循环的核心不是一个会自己运行的智能对象,而是:

text 复制代码
模型提出动作 -> 代码检查执行 -> 返回执行结果 -> 模型决定下一步

实现可以拆成六步

  1. 建立只有 system 与 user 的 messages,选定当前角色的工具列表。
  2. 经 Budget.invoke 占用额度,调用 model.chat。
  3. 保存 assistant 发出的 tool_calls,检查同一回复里的调用 ID 不重复。
  4. 普通工具交给 dispatch,权限和参数都通过才执行。
  5. 把结果作为 role=tool 消息回填,每条携带原 tool_call_id。
  6. 收到单独的终止工具时,验证结果并返回;超过轮数则失败。

模型如果只是说"我已经完成了",程序会提醒它调用终止工具,而不是直接保存那段文本。

面试时这样说

我用显式循环实现 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 便宜吗"讲一遍。以下是说明流程的示例,不是保证模型每次都输出同样的查询:

  1. 换问法: 除原问题,还查"部署维护成本""资源需求对比",减少只靠一种措辞漏资料的可能。
  2. 找候选: ES 按词找,Milvus 按语义找。两者都返回片段编号,代码按编号把重复段落合并。
  3. 确定阅读次序: RRF 先综合各列表的名次;Rerank 再看问题与候选正文的关系,重新排先后。
  4. 检查能否回答: 资料只说明需要监控备份,没有实际费用。它"与费用有关",但不能证明谁更便宜。
  5. 决定是否补查: 评估模型提出一个新的查法时,程序最多再查一次;仍不足则带着缺口返回。

评估结果在代码里用四个字段表达。先记含义,再记英文:

字段 白话含义 例子
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 的完整生命周期

  1. 校验已登记任务、依赖是否完成,检查累计任务数并预留名额。
  2. 创建 parent=False 的 AgentState,seen 与 searches 都是新的。
  3. 等待 Semaphore 执行名额,增加活跃数与峰值统计。
  4. 用新 messages 研究,可检索和读写自己的工作文件,但不能 plan/task/save_report。
  5. 检查终止结果,保证引用来自它自己的 seen。
  6. 成功后为每条发现登记 finding_id,只回传限长 findings/gaps 和文件元数据,不回传原文,不更新父 seen。
  7. 在 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 超时,却不是向量算法坏了

现象: 既有向量接口连接超时,无法完成混合检索。单元测试能通过,因为模拟网络不会复现机器上的代理路由。

排查顺序:

  1. 用固定短文本单独请求 Embedding,先把 Agent 调度排除。
  2. 默认路径失败;显式走系统代理也 ConnectTimeout。
  3. 系统没有常见的代理环境变量,但 Windows 代理解析仍能返回代理设置。
  4. 保持端点、密钥、模型和输入不变,显式 trust_env=False 直连,得到 HTTP 200、1024 维向量。
  5. 补代理开关和独立向量配置,再通过真实 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 把外部 HTTP choices/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 则把路径与内容哈希规则封装起来。这里的哈希只是单进程乐观并发检查,不是分布式锁;任务状态也不是可恢复的工作流引擎。

设计模式面试追问的四层回答

  1. 白话层: "小任务主自己做,独立研究面才交给子;子不把全部过程塞回来。"
  2. 实现层: "child 新建 AgentState 和 messages,tools_for(False) 去掉 plan/task/save_report,Coordinator.finish 登记 finding。"
  3. 工程层: "共享 Budget 与 Semaphore 做限额,依赖由 start 检查,WorkFiles 用内容哈希拒绝旧编辑。"
  4. 边界层: "不是进程沙箱、分布式锁或恢复型队列;语义正确性仍需更大评测和人工核对。"

被追问时按层递进,不要第一句就念完整代码。每层都应能指回理解文档中的状态变化例子。

不要说"我用了设计模式所以可扩展"。应先讲具体变化:换检索器不用改 Agent 循环;子返回格式改变只需调整契约与登记模块;文件路径和版本规则集中在 WorkFiles。接口让调用方少操心才有意义。

为什么选 Python,没照搬原学习代码

检索、文本处理、模型接口与异步调度可以在同一个包里直接组合。项目用显式循环理解执行过程,避免跨两个运行时传状态。不是说 TypeScript 不能实现,而是为这个小工具减少额外复杂度。

为什么不用 LangGraph

当前状态较少,没有持久化检查点、人工审批和长任务恢复。显式 loop 足以把消息、工具回填、权限和取消看清楚。将来真需要这些状态管理能力,再评估框架,而不是为了简历列名词。

为什么用了 ES/Milvus,却没再加任务数据库和队列

ES/Milvus 解决资料和向量持久化,不等于 Agent 任务也可恢复。当前 CLI 在一个进程里等待任务,尚无持久消息队列和中断续跑。把两种持久化区分清楚,比继续堆组件更重要。

数据规模仍限制为 400 个片段,不宣称百万级压测。已有存储服务让索引复用和真实召回更容易演示,但多用户场景还需要鉴权、隔离和任务状态管理。

为什么没有保留通用 Shell

原 Python 学习脚本具备 Shell 执行能力,而研究资料只需要读与检索。黑名单拦几个危险命令很容易漏掉绕过方式,所以这里直接不提供 Shell,而不是继续扩充危险词列表。

最值得优先改进什么

结合实际暴露的问题回答,而不是先报中间件:

  1. 扩充独立标注题集,尤其是无答案题与结论是否被证据支持。
  2. 在限长结果和原文不自动回灌的基础上,精确限制上下文 token;当前 context_chars 只是字符观测,不是 token 预算。
  3. 在已实现的快照复用上增加逐片段向量缓存,减少新快照全量向量化。
  4. 给旧快照增加保留/清理策略,再做受控增量更新与并发入库协调。
  5. 真要服务多用户时,先补鉴权、上传隔离、任务持久化与恢复,再做 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% 吗? 不行,检索文档覆盖与回答正确性不同,样例规模也小

完成标准不是把名词复述出来,而是你能运行对应命令、打开对应函数、拿一个具体输入解释它如何变成输出。做到这一点,这个小项目就能成为你真正讲得清楚的项目,而不是简历上的一组关键词。

相关推荐
信誓旦旦的程序猿1 小时前
【Python 量化取数指南 #13】Python 把行情落库:sqlite 一键存,回测随用随取
java·python·股票数据api·股票数据·股票数据api接口·股票api数据接口·股票量化数据api
拍手笑沙鸥1 小时前
系列4之「超体」工具调用与 Function Calling:给智能体装上"双手"
aigc·agent
Luhui_Dev1 小时前
数学问题在 Agent 实践中的难点
人工智能·数学·agent
LensonYuan1 小时前
Cursor状态栏一键切换OpenAI API Key
人工智能·python·cursor
柒和远方1 小时前
DocResearch 项目理解:从一条命令到一份有出处的报告
python·llm·agent
北冥有鱼被烹1 小时前
NVIDIA DGX/HGX 服务器全景深度解析:从 H100 到 GB300,专业算力选型必读
运维·服务器·网络·python
Maiko Star1 小时前
* LangChain Agent智能体详解(上):创建调用、工具绑定与系统提示词
python·langchain·agent
Data_Journal2 小时前
如何使用 Python Requests 绕过 CAPTCHA
python
七夜zippoe2 小时前
Agent 自主决策机制:什么时候该检索、什么时候该直接回答
人工智能·ai·agent·检索·自主决策