首页 核心备考 STAR 案例 评估与护栏 评估深入 护栏深入 核心概念 意图识别 EN

意图识别 · 第 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。不调模型,没有延迟,也没有波动。

值得讲出来的设计论证 这里的槽位抽取(slot extraction)是一个 封闭词表(closed-vocabulary) 问题——公司恰好只有五家,支持的财年只有三个。当合法取值的集合又小、又固定、又提前已知时,正则在每一个真正重要的维度上都胜过模型:免费、瞬时、完全可复现,而且它不可能幻觉出第六家公司。词表是开放的时候才该上模型,而不是面对一份自己写出来的清单时。

还有一个诚实的但书,主动交代比被人发现要好:同样的模式存在于 三个不同的地方 — nodes/memory.py, utils/chart.py,以及评估脚本(eval runner)里再一份。同一个 ticker 正则有三份互相独立的副本,这是等着漂移的重复代码,正确的修法是抽成一个共享模块。

在分类之前,先把追问解析掉

nodes/memory.py — _enrich_query。让多轮对话真正能用的那一步。

问题是这样的。用户连着问了两个问题:

第 1 轮:  「BHP 的 FY2024 收入是多少?」
第 2 轮:  「他们的股息呢?」

第 2 轮单独拿出来是无法路由的。里面既没有公司也没有年份——「他们的」同时承载了这两者,而它只有在第 1 轮的语境下才有意义。原样丢给路由器,最好的结果也不过是问出一个用户早就已经回答过的澄清问题。

所以在路由之前,节点会倒着扫描前六条消息,从最新的开始,只把当前查询中 缺失的 槽位补上去:

补全后:  「他们的股息呢? [BHP, FY2024]」

三个细节让这一步显得便宜而不是聪明:如果查询里两个槽位都已经存在,它会立刻退出;它是倒序扫描的,所以当对话从一家公司转到另一家时,最近一次提及会胜出;它是追加一个提示而不是重写句子,因此用户原本的措辞一点都不会丢。

这就是指代消解 搞清楚「他们的」指的是 BHP,是一个经典的语言学问题,通常用模型来解。这里用的是对最近六条消息跑正则,因为真正需要消解的指代对象只有五个 ticker 和一个年份。和上面槽位抽取是同一个论证——词表是封闭的,所以便宜的解法同时也是正确的解法。

把意图整个跳过的那道短路

memory/semantic.py ——两个阈值,三种结果。

在路由之前,查询会按语义而不是字面,和之前答过的问题作比较。一个相似度分数,两个阈值:

CACHE_THRESHOLD   = 0.95   # 直接返回旧答案
CONTEXT_THRESHOLD = 0.80   # 作为上下文注入流水线
分数会发生什么为什么
≥ 0.95cache_hit → 直接到 answer实际上就是同一个问题。检索、路由和综合全部跳过。
0.80 – 0.95整条流水线照跑,但过去的那个答案会被注入到综合环节,标注为 「关于类似问题的过往分析(仅供参考)」相关,但不相同。当上下文有用,当答案不行。
< 0.80什么都不带过来不相关。

路由上的后果值得直说,因为它常让人意外:一旦缓存命中,意图分类器 根本不会运行。系统在从没问过这个问题的情况下就决定了你想要什么,理由是它之前答过。

中间那一档才是更有意思的设计选择。二元缓存要么命中要么落空;这一个多了一个区间,把记忆当作提示而不是答案——这样既能从「差一点」的命中里拿到价值,又不会把陈旧答案端给一个其实并不相同的问题。

事实上的意图分类器

nodes/retrieve_decision.py ——一次 LLM 调用,四个标志位,外加围绕它的三道确定性守卫。

别人问这里的意图识别是怎么做的,要讲的就是这个节点。它一次性回答四个问题:

1
is_out_of_scope ——这到底属不属于我们的领域?

对任何与这五家公司或其财务无关的内容为真。提示词直接点名了失败情形: 写诗、天气、做饭、编程求助.

2
needs_retrieval ——我们需要年报吗?

对指标类问题为真: 收入、利润、EBITDA、资本开支、股息、产量、债务、现金流。明确为假的是 打招呼、关于矿业概念的通识问题、关于你自己如何工作的问题、道谢.

3
needs_news ——我们需要实时的外部上下文吗?

对以下情况为真: 近期事件、最新新闻、当前市场行情、分析师展望。它与上一个标志位相互独立,而这正是整件事的关键。

4
needs_clarification ——这是不是含糊到没法往下走?

刻意保守: 只有当问题含糊到检索根本无法有意义地进行时才为真 ——提示词自己举的例子是「介绍一下这家公司」,却没点名任何公司。

外加两个自由文本输出: clarification_question,以及 direct_answer ,用于模型不需要任何检索就能处理的查询。

提示词内部,以及围着它的那些守卫

提示词不是故事的全部。真正让它安全的,是包在外面的那圈确定性代码。

系统提示词一开头就把这份工作点得很准,正是概念页会推荐的写法——一个角色、一个领域、一个决策:

你是一个 ASX 矿业财报聊天机器人的
路由智能体。分析用户的问题,
并决定该如何处理它。

接着它为每个标志位给出包含与排除的情形,最后是一段 few-shot 块,把七个示例查询映射到对应的标志位组合。这个结构——描述、包含、排除、示例——正是第一部分讲的意图定义形态,只不过应用在布尔值而不是标签上。

三道不信任模型的守卫

1
空查询短路

空白或只含空格的查询会立即返回, 在任何 LLM 调用之前。没必要花一次调用去分类一个空的东西。

2
强制检索

如果模型说不需要检索,却又没有给出 direct_answer,代码会覆盖它,照样去检索。源码里的注释说得很直白:模型既拒绝干活, 又 拒绝回答,那唯一安全的读法就是它把这个决策做错了。

3
异常时失败放行

如果这次调用抛异常,节点会返回 needs_retrieval=True, needs_clarification=False, is_out_of_scope=False。注释是: 失败安全——总是检索,绝不因为澄清而阻断.

失败放行 vs 失败拦截——要准备好为它辩护 本项目里每一个 LLM 决策节点都是失败 放行:出错时照做,而不是拒绝。这在 这里 是对的选择,因为最坏情况不过是白检索一次——这个智能体只读文档,它没法转账,也改不了任何记录。如果一个系统的工具会做出不可逆的动作,同样的默认值就完全站不住脚,护栏必须失败拦截。有意思的回答讲的是背后的推理,而不是这个设置本身。

还有一道更小的守卫:如果模型要求澄清却把问题留空,就会替换成一句硬编码的兜底—— 「能否说明一下您问的是哪家公司(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」仍然是一条查询,因为里面只有一家公司 —— 而它照样能给出正确结果,因为两个数字都躺在同一批文档里,一次检索就能同时拿到。这是一个恰好在本领域无害的限制,而这正是该拿来描述它的方式:知道天花板在哪,也知道自己为什么还没撞上去。

超出范围,分三层拦

第一部分强调过要留一个逃生舱。这里有三个,深浅不同。

1
在路由层 —— is_out_of_scope

在任何东西跑起来之前就拦下明显的情况。直接路由到 answer,返回一句写死的回复,把真实的服务范围讲清楚: 「我只能回答关于 BHP、RIO、FMG、MIN 和 NST 年报财务数据的问题。」 置信度被设成空而不是 high —— 它不是一个答案,所以也不会按答案那样被评分。

2
在护栏层 —— 一个长度阈值

刻意不用 LLM 调用。如果拼装出来的上下文不足 10 个字符, guardrails_passed 就为假,整次运行转向 fallback。粗糙、瞬时,但正好能抓住检索悄无声息什么都没返回的情况。

3
在兜底层 —— 明确说出哪些内容 是 支持的

把失败原因归成三类 —— 检索到了文档但没合成出来、根本什么都没检索到、或者检索到的太少不足以用 —— 然后显式附上服务范围:那五家公司、FY2023–FY2025 财年,以及支持的指标。

中间那道缝隙值得主动认下来:一个 不支持的 公司或年份并没有显式检查。「Apple 的 FY2024 收入是多少?」不会被 is_out_of_scope 拦下 —— 它确实是个财务问题,只是问错了公司 —— 于是它会走完检索,什么也找不到,最后被护栏和兜底接住。用户拿到的结果是对的,只是走了一条比必要更长、更贵的路。恰好有一个评估用例覆盖这件事,所以这是「已知」而不是「猜的」。

有一个默认值值得再看一眼。护栏那条路由判断写的是 guardrails_passed is False —— 这是身份判等(identity check),所以一个 缺失的 标志位会被路由到 answer,而不是 fallback。这是有意为之,测试里也写明了:缺失意味着上下文压根没被检查过 —— 比如直接回答那条路径 —— 所以应当放行而不是拦截。一处微妙的失败放行(fail open),特意记了下来。

路由到底是怎么被评估的

evals/ —— 一个专门的路由套件、一个边界套件,以及垫在两者底下的单元测试。

把第一部分的四层评估,对照到这里实际存在的东西:

层级由什么覆盖怎么做
1 · 意图evals/cases/routing.jsonl8 个用例,四个标志位全部精确匹配,外加 needs_calculation
2 · 槽位同一套件对公司、财年、指标做子集包含判断
3 · 工具/路由单元测试覆盖五个路由函数的 18 个测试,全程不涉及 LLM
4 · 端到端evals/cases/boundary.jsonl6 个用例,检查用户最终看到的那个答案

路由套件直接跑路由节点,而不是整张图 —— 先分类,如果要检索再改写 —— 这让它快到可以经常跑。用例从 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 没有被任何评估用例断言过。后果最重的那个标志位 —— 决定系统到底要不要接这个活的那个 —— 恰恰是没有直接测试的。这会是我补的第一个用例。

认下来的弱点

主动说出来,读起来是工程判断力;被人当场问出来,读起来正好相反。

1
对某些查询来说,clarify 分支根本走不到

needs_clarification 只有在 needs_retrieval 或 needs_news 为真时才会被采纳。一个模型认为两者都不需要的模糊查询,会一路掉到 guardrails,它生成的那个澄清问题就被悄悄丢掉了。一行代码就能修,也是一个很好的例子:路由函数内部的判断顺序本身就是一个设计决策。

2
澄清环节没有循环计数器

子图里的两个重试循环上限都是 2,clarify 循环没有。实际中它会停下来,因为合并后的查询每一轮都更具体 —— 但那是一个论证,不是保证。

3
同一套实体正则有三份拷贝

股票代码和财年的正则在 memory 节点、图表工具和评估执行器里各写了一份。加第六家公司时,你得同时记住这三个地方。

4
一段过期的 docstring

memory 节点的 docstring 写着缓存阈值是 0.85,而常量其实是 0.80 和 0.95。事情很小,但正是这类漂移会让人不再相信其余的文档。

5
任何路由决策上都没有置信度

前面讲过。在所有分支都是只读的前提下可以接受;一旦要加会写数据的工具,这是第一件要修的事。

6
子图的路由函数没有单元测试

supervisor 的五个路由函数一共有 18 个测试。检索子图的那三个 —— 扇出和两道重试闸门 —— 一个都没有,尽管里面装的正是防止死循环的重试上限逻辑。

怎么把它讲出来

如果他们问你意图识别是怎么做的 —— 九十秒讲完。

结论

「没有独立的意图分类器模块 —— 意图就是一次结构化 LLM 调用输出的四个标志位,从它岔出去的每条分支都是确定性的代码。」

理由

「用标志位而不是单个标签,是因为两个意图可能同时成立 —— 年报和新闻 —— 所以多意图是输出结构自带的,而不是后来硬加上去的。」

证据

「八个路由评估用例,其中一个是显式的多意图用例;路由函数上有 18 个单元测试;还有一个边界套件,覆盖提示注入和不支持的公司。」

然后是三个能显出你走得比顺利路径更深的点,大致按现场效果从好到次排序:

  • 槽位用正则,这是个决策。 五个股票代码加三个财年是一个封闭词表 —— 上模型会更慢、更贵、不确定,还可能凭空造出第六家公司。词表开放的地方才用模型。
  • 有一个决策是刻意推迟做的。 要不要算数,是在检索 之后 才决定的,因为只有到那时你才看得到年报是不是已经直接写了增长率。在证据最充分的那个点做路由。
  • 每个 LLM 节点都是失败放行,而这之所以站得住脚,唯一的原因是这里没有任何东西会写数据。 最坏的结果不过是一次白跑的检索。换成一个会动钱的工具,这个默认就必须反过来。
收尾落在短板上,而不是功能上 「我最想先修的,是路由决策上没有置信度 —— 系统因此没办法把一个临界的路由升级成澄清。这能扛得住,是因为每条分支都是只读的。要是这个智能体真能动手做事,就扛不住了。」

这个收尾在面试间里最起作用:它说明你分得清「你造出来的东西」和「同一套设计放进银行还需要什么」之间的差别 —— 而那才是这场面试真正的题目。