意图识别 · 第 2 / 2 部分
Mines Financial 中的意图识别
一个面向五家 ASX 矿业公司的财报智能体(agent)。项目里没有任何一个文件叫做 intent_classifier.py ——而把「为什么没有」讲精确,比真有这么一个文件更是一个好答案。本页按实际执行顺序,走一遍系统判断你想要什么的每一个环节,并标出各自所在的文件。
简短的答案
意图是存在的,只是没有以标签(label)的形式存下来。
最接近意图分类器的,是一个节点, nodes/retrieve_decision.py。它只发一次 LLM 调用,返回一个结构化输出(structured output)对象——但那个对象不是一个意图名,而是 四个相互独立的布尔值:
class _RoutingDecision(BaseModel):
is_out_of_scope: bool
needs_retrieval: bool
needs_news: bool
needs_clarification: bool
clarification_question: str
direct_answer: str
在代码库里搜 intent 这个词,找到的只有注释和文档里的散文。两个 state schema 里都没有 intent 字段。真正存在的是一个 标志位向量(flag vector),而这个选择带来一个值得点名的具体后果:
needs_retrieval 和 needs_news 可以同时为真。多意图(multi-intent)不是事后加上去的功能,而是输出形态本身就已经允许的事。「FMG 的 FY2024 收入是多少?另外他们最近有什么新闻?」会同时置起两个标志位,并让两条分支并行运行。
流水线里的其余部分,要么是读取这些标志位的确定性边,要么是等到有更多证据之后再做的、范围更窄的决策。本页剩下的内容就是这个顺序。
系统的形态
两张图。一张主控图(supervisor graph),以及作为单个节点挂载在其中的检索子图(retrieval subgraph)。
SUPERVISOR (state: MainState) compress_context ← 入口节点 ↓ memory ← 实体, 追问, 缓存 ↓ (缓存命中? → answer) retrieve_decision ← 四个标志位 ↓ ├─ out of scope → answer (固定话术) ├─ vague → clarify → 回到 retrieve_decision ├─ needs retrieval → retrieval_agent ⎫ 并行 ├─ needs news → news_agent ⎭ └─ neither → guardrails ↓ aggregate ← 合并来源, 判断是否要算术 ↓ (要算术? → calculator_agent) guardrails ← 上下文到底够不够 ↓ answer | fallback
RETRIEVAL SUBGRAPH (state: RetrievalState) query_rewrite ← 按公司拆分查询 ↓ retrieve_company ×N ← 每家公司一次, 并行 ↓ grade_docs ← 只保留相关文档 ↓ (没有可用的? → 重试, 最多 2 次) synthesize ← 起草答案 ↓ grade_answer ← 质量 + 有据性 ↓ (有无依据论断? → 重试, 最多 2 次) END
两张图里的每一次 LLM 决策都用同一套机制—— llm.with_structured_output(Model, method="function_calling") ,温度为 0,模型是 DeepSeek。 function_calling 这个 method 是被迫的而不是选出来的:源码里反复出现的注释记录了 DeepSeek 不接受 json_schema 这种响应格式。细节很小,但很真实,也正是那种值得提一句、能证明你是自己做过而不是读来的东西。
决策之前:压缩历史
nodes/compress_context.py ——图的入口节点。
这里还没涉及意图,但它决定了分类器最终会看到什么。一旦对话超过十条消息,较早的会被摘要,最近四条原样保留:
COMPRESS_THRESHOLD = 10 KEEP_RECENT = 4
它之所以和这里相关,是因为摘要提示词本身就是面向实体的——它被要求恰好保留路由器稍后会用到的那些东西:
重点保留:讨论过的 ASX 矿业公司、 提到的财务指标(收入、利润、 EBITDA、资本开支、股息)、财年, 以及已经得出的关键结论。
如果摘要失败,节点会返回 compressed: False 并原样保留消息,而不是把它们丢掉——这是本代码库里若干个失败放行(fail open)选择中的第一个。
槽位抽取,故意用正则来做
nodes/memory.py ——在任何模型看到查询之前,先抽出公司和财年。
概念页把意图和槽位分开讲。本项目先抽槽位,而且是确定性地抽:
_TICKER_RE = r"\b(BHP|RIO|FMG|MIN|NST)\b"
_FY_RE = r"\bFY20\d{2}\b"
_ALIASES = {
"fortescue": "FMG",
"rio tinto": "RIO",
"mineral resources": "MIN",
"northern star": "NST",
}
两个槽位,都是封闭集合:五个 ticker,以及匹配固定模式的财年,并做归一化,让 fy2024 变成 FY2024。不调模型,没有延迟,也没有波动。
还有一个诚实的但书,主动交代比被人发现要好:同样的模式存在于 三个不同的地方 — nodes/memory.py, utils/chart.py,以及评估脚本(eval runner)里再一份。同一个 ticker 正则有三份互相独立的副本,这是等着漂移的重复代码,正确的修法是抽成一个共享模块。
在分类之前,先把追问解析掉
nodes/memory.py — _enrich_query。让多轮对话真正能用的那一步。
问题是这样的。用户连着问了两个问题:
第 1 轮: 「BHP 的 FY2024 收入是多少?」 第 2 轮: 「他们的股息呢?」
第 2 轮单独拿出来是无法路由的。里面既没有公司也没有年份——「他们的」同时承载了这两者,而它只有在第 1 轮的语境下才有意义。原样丢给路由器,最好的结果也不过是问出一个用户早就已经回答过的澄清问题。
所以在路由之前,节点会倒着扫描前六条消息,从最新的开始,只把当前查询中 缺失的 槽位补上去:
补全后: 「他们的股息呢? [BHP, FY2024]」
三个细节让这一步显得便宜而不是聪明:如果查询里两个槽位都已经存在,它会立刻退出;它是倒序扫描的,所以当对话从一家公司转到另一家时,最近一次提及会胜出;它是追加一个提示而不是重写句子,因此用户原本的措辞一点都不会丢。
把意图整个跳过的那道短路
memory/semantic.py ——两个阈值,三种结果。
在路由之前,查询会按语义而不是字面,和之前答过的问题作比较。一个相似度分数,两个阈值:
CACHE_THRESHOLD = 0.95 # 直接返回旧答案 CONTEXT_THRESHOLD = 0.80 # 作为上下文注入流水线
| 分数 | 会发生什么 | 为什么 |
|---|---|---|
| ≥ 0.95 | cache_hit → 直接到 answer | 实际上就是同一个问题。检索、路由和综合全部跳过。 |
| 0.80 – 0.95 | 整条流水线照跑,但过去的那个答案会被注入到综合环节,标注为 「关于类似问题的过往分析(仅供参考)」 | 相关,但不相同。当上下文有用,当答案不行。 |
| < 0.80 | 什么都不带过来 | 不相关。 |
路由上的后果值得直说,因为它常让人意外:一旦缓存命中,意图分类器 根本不会运行。系统在从没问过这个问题的情况下就决定了你想要什么,理由是它之前答过。
中间那一档才是更有意思的设计选择。二元缓存要么命中要么落空;这一个多了一个区间,把记忆当作提示而不是答案——这样既能从「差一点」的命中里拿到价值,又不会把陈旧答案端给一个其实并不相同的问题。
事实上的意图分类器
nodes/retrieve_decision.py ——一次 LLM 调用,四个标志位,外加围绕它的三道确定性守卫。
别人问这里的意图识别是怎么做的,要讲的就是这个节点。它一次性回答四个问题:
is_out_of_scope ——这到底属不属于我们的领域?
对任何与这五家公司或其财务无关的内容为真。提示词直接点名了失败情形: 写诗、天气、做饭、编程求助.
needs_retrieval ——我们需要年报吗?
对指标类问题为真: 收入、利润、EBITDA、资本开支、股息、产量、债务、现金流。明确为假的是 打招呼、关于矿业概念的通识问题、关于你自己如何工作的问题、道谢.
needs_news ——我们需要实时的外部上下文吗?
对以下情况为真: 近期事件、最新新闻、当前市场行情、分析师展望。它与上一个标志位相互独立,而这正是整件事的关键。
needs_clarification ——这是不是含糊到没法往下走?
刻意保守: 只有当问题含糊到检索根本无法有意义地进行时才为真 ——提示词自己举的例子是「介绍一下这家公司」,却没点名任何公司。
外加两个自由文本输出: clarification_question,以及 direct_answer ,用于模型不需要任何检索就能处理的查询。
提示词内部,以及围着它的那些守卫
提示词不是故事的全部。真正让它安全的,是包在外面的那圈确定性代码。
系统提示词一开头就把这份工作点得很准,正是概念页会推荐的写法——一个角色、一个领域、一个决策:
你是一个 ASX 矿业财报聊天机器人的 路由智能体。分析用户的问题, 并决定该如何处理它。
接着它为每个标志位给出包含与排除的情形,最后是一段 few-shot 块,把七个示例查询映射到对应的标志位组合。这个结构——描述、包含、排除、示例——正是第一部分讲的意图定义形态,只不过应用在布尔值而不是标签上。
三道不信任模型的守卫
空查询短路
空白或只含空格的查询会立即返回, 在任何 LLM 调用之前。没必要花一次调用去分类一个空的东西。
强制检索
如果模型说不需要检索,却又没有给出 direct_answer,代码会覆盖它,照样去检索。源码里的注释说得很直白:模型既拒绝干活, 又 拒绝回答,那唯一安全的读法就是它把这个决策做错了。
异常时失败放行
如果这次调用抛异常,节点会返回 needs_retrieval=True, needs_clarification=False, is_out_of_scope=False。注释是: 失败安全——总是检索,绝不因为澄清而阻断.
还有一道更小的守卫:如果模型要求澄清却把问题留空,就会替换成一句硬编码的兜底—— 「能否说明一下您问的是哪家公司(BHP、RIO、FMG、MIN、NST)、哪个财年、哪个指标?」 结构化输出只保证字段存在,并不保证它有用。
路由边是纯代码
graph.py — route_retrieve_decision。没有模型,没有歧义。
这些标志位由一个普通函数读取。它值得全文引用,因为「短」正是重点所在:
if is_out_of_scope: → "answer"
if needs_retrieval or needs_news:
if needs_clarification: → "clarify"
→ 扇出到各 agent
→ "guardrails"
四行确定性逻辑,把概率性的那部分和它的后果隔开。模型决定 是什么;代码决定 会发生什么。正是这种分离让行为可测试——这张图里的五个路由函数上有 18 个单元测试,没有一个会调 LLM。
优先级顺序本身就是一句设计声明: 超出范围压倒一切,澄清压过检索,而任何都不匹配的情况会落到护栏,而不是直接落到 answer。
澄清是一次真正的中断,不是一条消息
nodes/clarify.py ——图停下来,用户回答,图再继续。
当查询太含糊时,节点会调用 LangGraph 的 interrupt,它会在图执行到一半时挂起。API 把它作为一个事件抛给前端,而用户的回复会用 Command(resume=...):
user_answer = interrupt({"question": question})
merged_query = f"{original_query} {user_answer}"
return {"query": merged_query,
"needs_clarification": False}
合并后的查询随后回到 retrieve_decision ,从头重新分类一遍。这就是概念页那套澄清策略的落地实现——但有一处差别值得注意:用户的回答是被 追加到 原查询后面,而不是替换它,所以用户第一次说过的话一点都不会被丢掉。
retry_count < 2。而 clarify 循环 没有这样一个计数器 ——如果路由器一直判定查询含糊,它可能无限来回弹。实际情况是合并后的查询每过一轮都会变长、变具体,所以它会终止;但「实际上会终止」不是一个界,而修法只是一个整数。
查询拆解:一个问题,多次检索
retrieval_nodes/query_rewrite.py ——多公司问题在这里被拆开。
在检索子图内部,第二次 LLM 调用会重读这个查询,产出三样东西:
global_query: str
companies: list[str]
company_queries: list[{company, query}]
它的指令在「拆分」这件事上非常强硬: 只要提到多家公司,就总是为每家公司生成一条,即使各家要的是同一个指标。提示词里给出的示例:
Input:
"What was BHP's FY2023 profit and
RIO's FY2024 dividends?"
Output:
companies = ["BHP", "RIO"]
company_queries = [
{"company": "BHP", "query": "BHP FY2023 profit"},
{"company": "RIO", "query": "RIO FY2024 dividends"},
]
这是第二个意图维度。路由器决定的是 用哪些来源;这里决定的是 要做多少次检索、针对哪家公司的文档。每一条都会变成一次独立的检索,并施加两道硬性公司过滤——一次在向量库查询时,另一次在去重时再来一遍。
该节点还会初始化一个按公司统计的完成度追踪器, company_status,这正是下一节里那种定向重试(targeted retry)得以成立的基础。
扇出,以及会收窄的重试
agents/retrieval_agent.py —— 并行派发,加上两个带上限的重试循环。
每家公司的查询会并行派发出去。如果查询里根本没点名任何公司 —— 也就是一个泛化问题 —— 那就改为向全部五家广播:
if company_queries:
one Send per company
else:
Send to all of BHP RIO FMG MIN NST
接着是两道彼此独立的重试闸门(retry gate),上限都是两次尝试:
| 闸门 | 触发重试的条件 | 回到 |
|---|---|---|
紧跟 grade_docs | 没有任何文档通过相关性评分 | query_rewrite |
紧跟 grade_answer | 草稿没通过质量评分, 或 某家公司仍然一无所获, 或 ≥ 2 条无依据论断 | query_rewrite |
真正值得拿出来讲的,是回程路上发生的事。 query_rewrite 在重试时并 不会 再调一次 LLM。它读的是 company_status,算出还有哪几家公司缺数据,只为这几家重建查询 —— 并把失败的论断标签(claim label)追加成额外的检索词:
missing = [c for c, found in company_status.items()
if not found]
hints = [h for h in unsupported_hints if c in h]
query = f"{base} {' '.join(hints)}"
这里有一处很巧妙、容易被漏掉、又很值得点出来的耦合:评分提示词(prompt)要求无依据论断的标签必须控制在 八个词以内,而且不带具体数字 —— "RIO FY2025 revenue",而不是整句话。这条约束之所以存在,是因为这些标签会被复用为检索查询,也因为要靠在标签内部做子串匹配找出股票代码,来决定重新去取哪家公司的数据。一条提示词的格式规则,却承载着整个重试机制。
一个刻意推迟的决策
nodes/aggregate.py —— 「这题需要算数吗?」这个问题放在检索之后问,而不是之前。
一个答案要不要计算,显然可以在最前面就从查询里定下来 —— 它看上去不过是又一个路由标志位。但这里偏偏放到后面才决定,源码的注释里说明了原因:这个决定是 在能看到实际检索回什么数据之后才做的,而不是仅凭查询去猜。
早决策
「BHP 的收入增长率是多少?」听起来就是要算数。但如果年报里直接写了增长率,再算一遍就是多余的活,还多了一次把数字算错的机会。
晚决策
文档已经拿在手上:是有必须组合的原始数字,还是答案本来就摆在那儿?只有后一种问法才能问对。
提示词里就是这么写的 —— needs_calculation 为真 仅当上下文里含有必须被组合计算的原始数字时,为假 则是在上下文里已经有一个算好的现成答案时。
这个节点还负责把并行的分支合并回来,放在固定的分节标题下 —— 【Annual Report Data】 和 【Recent News】 —— 这样下游的 answer 和有据性(groundedness)检查器才分得清某条事实来自哪个来源。
唯一一处由模型自己挑工具的地方
agents/calculator_agent.py —— 其他地方的「工具选择」都是一条图的边。
第一部分讲过第三种实现风格:意图隐含在工具选择里。本项目只在一个地方用了它。计算器是一个握着四个工具的 ReAct 智能体,可以在它们之间自由选择:
| 工具 | 作用 |
|---|---|
calculate | 普通算术表达式 |
calculate_growth_rate | 从一组序列算期间增长率和 CAGR |
calculate_ratio | 财务比率 —— 利润率、ROE、ROA、负债权益比 |
calculate_average | 一组序列的平均值、总和、最小值和最大值 |
它的提示词里写着那条真正关键的约束: 绝不猜测或估算财务数字 —— 只使用上下文中已经出现的数字,而对增长率还要 确认时间先后顺序。
跟新闻智能体(news_agent)的对比才是重点。新闻工具写了一整段用于工具选择的 docstring —— 「用于近期事件……不要用于历史财务数字」 —— 而 从来没有任何模型读过它。它是被无条件调用的,因为「要不要用新闻」这个决定早就由 needs_news 在路由那一步做完了。那段 docstring 是死掉的元数据;能注意到这一点,就是「会描述自己的架构」和「真正了解自己的架构」之间的差别。
置信度是有的 —— 但不在你以为的地方
不在路由决策上,而在答案的有据性上。
第一部分说过,成熟的意图层输出的是一个标签 外加一个置信度。本项目在任何一个路由决策上都不输出置信度。系统里唯一那个置信度值是在最末尾推导出来的,而且靠的是数数:
n = number of unsupported claims
grounded = "yes" if n == 0
"partial" if n <= 3
"no" otherwise
confidence = {"yes": "high",
"partial": "medium",
"no": "low"}[grounded]
所以这里的「置信度」指的是 这个答案里有多少能追溯到某份检索回来的文档 —— 而不是路由器对「你到底想问什么」有多确定。当它落在 medium 或 low 时,答案后面会追加一段提醒,最多列出三条无依据论断,每条都标上它的依据类型:
| 依据(basis) | 展示给用户的文案 |
|---|---|
calculation | 根据年报数据计算得出 |
interpolation | 根据年报数据估算得出 |
general_knowledge | 来自 LLM 自身知识,年报中未找到 |
在 low这一档,还会再加一行,请用户回原始年报里交叉核对。记忆写入也被同一组信号把关 —— 只有通过评分、且不是无依据的答案才会被记住,这样语义缓存(semantic cache)就不会被一个糟糕答案污染,进而被后面的问题继承下去。
两个维度上的多意图
以及一个它没处理的维度 —— 那个才是更值得知道的事。
来源维度 —— 已处理
needs_retrieval 和 needs_news 同时为真 → 两条分支并行运行 → 最后由 aggregate合并到各自的分节标题下。
公司维度 —— 已处理
company_queries 把一个问题拆成每家公司一次检索 → 并行扇出 → 结果由共享状态上的 reducer 合并。
真正 没有 拆解的是指标维度。「BHP revenue and FMG dividends」能干净地拆开,因为拆分依据是公司。「BHP revenue and BHP capex」仍然是一条查询,因为里面只有一家公司 —— 而它照样能给出正确结果,因为两个数字都躺在同一批文档里,一次检索就能同时拿到。这是一个恰好在本领域无害的限制,而这正是该拿来描述它的方式:知道天花板在哪,也知道自己为什么还没撞上去。
超出范围,分三层拦
第一部分强调过要留一个逃生舱。这里有三个,深浅不同。
在路由层 —— is_out_of_scope
在任何东西跑起来之前就拦下明显的情况。直接路由到 answer,返回一句写死的回复,把真实的服务范围讲清楚: 「我只能回答关于 BHP、RIO、FMG、MIN 和 NST 年报财务数据的问题。」 置信度被设成空而不是 high —— 它不是一个答案,所以也不会按答案那样被评分。
在护栏层 —— 一个长度阈值
刻意不用 LLM 调用。如果拼装出来的上下文不足 10 个字符, guardrails_passed 就为假,整次运行转向 fallback。粗糙、瞬时,但正好能抓住检索悄无声息什么都没返回的情况。
在兜底层 —— 明确说出哪些内容 是 支持的
把失败原因归成三类 —— 检索到了文档但没合成出来、根本什么都没检索到、或者检索到的太少不足以用 —— 然后显式附上服务范围:那五家公司、FY2023–FY2025 财年,以及支持的指标。
中间那道缝隙值得主动认下来:一个 不支持的 公司或年份并没有显式检查。「Apple 的 FY2024 收入是多少?」不会被 is_out_of_scope 拦下 —— 它确实是个财务问题,只是问错了公司 —— 于是它会走完检索,什么也找不到,最后被护栏和兜底接住。用户拿到的结果是对的,只是走了一条比必要更长、更贵的路。恰好有一个评估用例覆盖这件事,所以这是「已知」而不是「猜的」。
有一个默认值值得再看一眼。护栏那条路由判断写的是 guardrails_passed is False —— 这是身份判等(identity check),所以一个 缺失的 标志位会被路由到 answer,而不是 fallback。这是有意为之,测试里也写明了:缺失意味着上下文压根没被检查过 —— 比如直接回答那条路径 —— 所以应当放行而不是拦截。一处微妙的失败放行(fail open),特意记了下来。
路由到底是怎么被评估的
evals/ —— 一个专门的路由套件、一个边界套件,以及垫在两者底下的单元测试。
把第一部分的四层评估,对照到这里实际存在的东西:
| 层级 | 由什么覆盖 | 怎么做 |
|---|---|---|
| 1 · 意图 | evals/cases/routing.jsonl | 8 个用例,四个标志位全部精确匹配,外加 needs_calculation |
| 2 · 槽位 | 同一套件 | 对公司、财年、指标做子集包含判断 |
| 3 · 工具/路由 | 单元测试 | 覆盖五个路由函数的 18 个测试,全程不涉及 LLM |
| 4 · 端到端 | evals/cases/boundary.jsonl | 6 个用例,检查用户最终看到的那个答案 |
路由套件直接跑路由节点,而不是整张图 —— 先分类,如果要检索再改写 —— 这让它快到可以经常跑。用例从 routing_001_greeting 一直到 routing_008_clarification_needed,其中有两个值得在面试里点名:
routing_007_report_plus_news—— 一个显式的多意图用例,期望needs_retrieval和needs_news同时为真,理由也写进了用例里:有些问题既需要历史年报数据,也需要当下的外部信息。routing_008_clarification_needed—— 「跟我讲讲这家公司。」期望同时触发检索 和 澄清,这就把路由边实现的优先级顺序钉死了。
边界套件测的是那些会把东西弄坏的边界:空查询、模糊的公司、不支持的公司(Apple)、超出范围的财年(FY2030)、无上下文触发护栏,以及一个 提示注入 用例,断言指令覆盖会被忽略。它还检查反向信号 —— 兜底答案里不能出现引用标记,并且必须用上预期短语之一,比如 not found 或 insufficient。
还有一处缺失值得主动提: is_out_of_scope 没有被任何评估用例断言过。后果最重的那个标志位 —— 决定系统到底要不要接这个活的那个 —— 恰恰是没有直接测试的。这会是我补的第一个用例。
认下来的弱点
主动说出来,读起来是工程判断力;被人当场问出来,读起来正好相反。
对某些查询来说,clarify 分支根本走不到
needs_clarification 只有在 needs_retrieval 或 needs_news 为真时才会被采纳。一个模型认为两者都不需要的模糊查询,会一路掉到 guardrails,它生成的那个澄清问题就被悄悄丢掉了。一行代码就能修,也是一个很好的例子:路由函数内部的判断顺序本身就是一个设计决策。
澄清环节没有循环计数器
子图里的两个重试循环上限都是 2,clarify 循环没有。实际中它会停下来,因为合并后的查询每一轮都更具体 —— 但那是一个论证,不是保证。
同一套实体正则有三份拷贝
股票代码和财年的正则在 memory 节点、图表工具和评估执行器里各写了一份。加第六家公司时,你得同时记住这三个地方。
一段过期的 docstring
memory 节点的 docstring 写着缓存阈值是 0.85,而常量其实是 0.80 和 0.95。事情很小,但正是这类漂移会让人不再相信其余的文档。
任何路由决策上都没有置信度
前面讲过。在所有分支都是只读的前提下可以接受;一旦要加会写数据的工具,这是第一件要修的事。
子图的路由函数没有单元测试
supervisor 的五个路由函数一共有 18 个测试。检索子图的那三个 —— 扇出和两道重试闸门 —— 一个都没有,尽管里面装的正是防止死循环的重试上限逻辑。
怎么把它讲出来
如果他们问你意图识别是怎么做的 —— 九十秒讲完。
结论
「没有独立的意图分类器模块 —— 意图就是一次结构化 LLM 调用输出的四个标志位,从它岔出去的每条分支都是确定性的代码。」
理由
「用标志位而不是单个标签,是因为两个意图可能同时成立 —— 年报和新闻 —— 所以多意图是输出结构自带的,而不是后来硬加上去的。」
证据
「八个路由评估用例,其中一个是显式的多意图用例;路由函数上有 18 个单元测试;还有一个边界套件,覆盖提示注入和不支持的公司。」
然后是三个能显出你走得比顺利路径更深的点,大致按现场效果从好到次排序:
- 槽位用正则,这是个决策。 五个股票代码加三个财年是一个封闭词表 —— 上模型会更慢、更贵、不确定,还可能凭空造出第六家公司。词表开放的地方才用模型。
- 有一个决策是刻意推迟做的。 要不要算数,是在检索 之后 才决定的,因为只有到那时你才看得到年报是不是已经直接写了增长率。在证据最充分的那个点做路由。
- 每个 LLM 节点都是失败放行,而这之所以站得住脚,唯一的原因是这里没有任何东西会写数据。 最坏的结果不过是一次白跑的检索。换成一个会动钱的工具,这个默认就必须反过来。
这个收尾在面试间里最起作用:它说明你分得清「你造出来的东西」和「同一套设计放进银行还需要什么」之间的差别 —— 而那才是这场面试真正的题目。