上线 1 个月的桌面 Agent,路由架构应该怎么演进?

Phased Routing Evolution for a One-Month-Old Desktop Agent

上周三晚上 11 点,老王给我发消息:他们的桌面 Agent 上线刚满 30 天,DAU 爬到 8000,团队 4 个人。他翻了一周用户行为日志,发现一个反直觉的事实——用过 3 次以上的用户里,62% 只把它当"自然语言版的快捷启动器"用,真正让它做跨应用编排的不到 13%。但他们的技术栈正按"复杂编排"在搭:每个 query 直接扔给 GPT-4o 做 function calling,P50 延迟 1.6 秒,P99 干到 3.8 秒。

老王问:“我看了你之前那个四层意图漏斗,要不要现在就全套上?团队就 4 个人,老板说三个月内要把日活做到 5 万,怕 over-engineering。”

我的答案:要上,但不是一次上四层。1 个月的 Agent 团队最缺的不是模型层数,是能让你做对决策的数据

本质问题:你不缺架构,你缺标注数据

我看到太多团队在月 1 就照搬"规则 + 缓存 + 微调小模型 + LLM"的完整方案,最后掉进同一个坑:为了那个还没有训练数据的微调小模型,提前搭了一堆基础设施,结果两个月过去线上跑的还是单 LLM,团队精力全耗在调框架上。

桌面 Agent 这类系统,决定演进顺序的不是技术复杂度,是数据成熟度

阶段       Query 日志量    标注覆盖    分布稳定性    可做的事
─────────────────────────────────────────────────────────────
月 1-2    < 50k           0%         很不稳定       规则 + LLM
月 3-4    50k-500k        > 60%      逐步收敛       + 缓存 + 小模型
月 6+     > 1M            > 80%      接近稳定       + 自进化闭环

老王的团队卡在月 1-2,但他想按月 6+ 的架构搭。这就是问题。

四层漏斗:方向对,但要分批上

先把目标架构摆在这里——这是未来 6 个月的终态,不是 1 个月的起点:

User Query
┌──────────────────────────────────────┐
│ Stage 1: 规则引擎                     │
│ 正则 + 关键词白名单,< 1ms              │
│ 命中率 ~50%,零 LLM 成本               │
└────────────┬─────────────────────────┘
             │ 未命中
┌──────────────────────────────────────┐
│ Stage 2: Embedding 缓存               │
│ FAISS 内存索引,< 20ms                 │
│ 命中率 ~25%(累计 75%)                │
└────────────┬─────────────────────────┘
             │ 置信度 < 0.92
┌──────────────────────────────────────┐
│ Stage 3: 微调小模型                    │
│ DistilBERT 领域微调,< 50ms            │
│ 命中率 ~15%(累计 90%)                │
└────────────┬─────────────────────────┘
             │ 模糊 / 复合意图
┌──────────────────────────────────────┐
│ Stage 4: 完整 LLM                     │
│ Claude Sonnet / GPT-4o,< 800ms       │
│ 覆盖率 ~10%,处理真正的复杂推理         │
└──────────────────────────────────────┘

加权后平均延迟从 1800ms 压到 100ms 左右。但这是有了 50 万条标注数据之后的样子。月 1 团队上来就建 Stage 3 小模型,相当于在没有水的河床上修水电站。

三阶段实施路径

我给老王画了一张时间轴。每个阶段只做这个阶段最该做的事:

                月 1-2 (现在)        月 3-4              月 6+
              ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
现在该做     │ 规则引擎      │    │ Embedding   │    │ 微调小模型   │
              │ + 数据管道    │    │ 缓存         │    │ + 自进化     │
              │ + LLM 兜底    │    │              │    │              │
              └─────────────┘    └─────────────┘    └─────────────┘
触发条件      上线即做           日 query > 5k       日 query > 50k
              已经有用户          + 标注 > 30%        + 分布稳定

接下来一节一节讲:每一阶段做什么、为什么是这个顺序、什么时候可以进下一阶段。

阶段 A(月 1-2):规则 + LLM + 数据管道

做这三件事,别的先别碰。

A.1 规则引擎:覆盖头部 30% 就够

不要追求"覆盖 50%"。月 1 的 query 分布还在剧烈变化,你今天写的规则下个月可能就过时。只兜那些绝对不会有歧义的 pattern:

import re
from dataclasses import dataclass
from enum import Enum
from typing import Optional


class Intent(Enum):
    APP_LAUNCH = "app_launch"
    FILE_OPEN = "file_open"
    WEB_SEARCH = "web_search"


@dataclass
class RuleMatch:
    intent: Intent
    confidence: float
    raw_query: str


# 月 1 只需要 5-10 条规则,覆盖头部场景
RULES: list[tuple[str, Intent]] = [
    (r"^(?:打开|启动|open)\s+(VS Code|Chrome|Terminal|Excel|Word|微信)", Intent.APP_LAUNCH),
    (r"^(?:搜索|搜|google|百度)\s+\S+", Intent.WEB_SEARCH),
    (r"^(?:打开|查看)\s+.{1,20}\.(pdf|docx?|xlsx?|md|txt)$", Intent.FILE_OPEN),
]


def rule_classify(query: str) -> Optional[RuleMatch]:
    for pattern, intent in RULES:
        if re.search(pattern, query.strip(), re.IGNORECASE):
            return RuleMatch(intent=intent, confidence=0.98, raw_query=query)
    return None
# generated by hugo AI

为什么不写更多? 月 1 你不知道哪些 pattern 会持续存在。写 50 条规则,3 个月后可能 30 条已经不再匹配真实分布,维护成本反而高。规则要后置补充,不是前置铺满。

A.2 数据管道:这才是月 1 最值钱的事

老王听到这一步说:“就埋点呗,能有多复杂?” 我让他打开自己的代码看了一眼——他们现在只记了 query + response,没有记意图标注、tool 选择、执行结果、用户后续行为。这种日志后面想做小模型微调,全得人工重标。

月 1-2 必须先把这套结构落下来:

import time
import uuid
from dataclasses import dataclass, asdict, field
from typing import Optional


@dataclass
class QueryTrace:
    trace_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    timestamp: float = field(default_factory=time.time)

    # 输入侧
    query: str = ""
    active_app: str = ""
    user_id_hash: str = ""

    # 路由决策
    classifier_stage: str = ""        # "rule" | "llm"
    predicted_intent: str = ""
    intent_confidence: float = 0.0
    selected_tool: str = ""

    # 执行结果
    tool_success: Optional[bool] = None
    latency_ms: int = 0

    # 隐式反馈(最重要!)
    user_undid: bool = False           # 用户是否撤销
    user_retried_within_30s: bool = False
    user_rephrased: bool = False       # 30s 内换了说法重问


def log_trace(trace: QueryTrace) -> None:
    # 写入你现有的日志系统:Loki / ClickHouse / S3 都行
    # 关键是 schema 必须从月 1 就稳定下来
    pass
# generated by hugo AI

为什么这是月 1 最重要的事? 因为月 3 你想训小模型时,能不能省掉 80% 的人工标注成本,全看月 1-2 这套日志结构合不合格。隐式反馈(undo、重试、改述)是最廉价、最准确的标注源——用户用脚投票了。

A.3 LLM 兜底:分类与执行解耦

老王现在的做法是把 query 直接交给 GPT-4o 做 function calling。我让他改成两步:

原方案:query → GPT-4o(function calling) → 直接执行
                ─────────────────────────
                一个调用同时做:意图识别 + 参数抽取 + tool 选择
                问题:决策不可缓存、不可审计、换模型成本高

新方案:query → GPT-4o(intent classification) → Router(规则+成功率) → tool
                ──────────────────────                ──────────────
                只做意图分类,结果可缓存              选 tool 的逻辑独立可测

为什么要拆?因为 Stage 2(embedding 缓存)能缓存的是"意图",不是"执行"。query “把这个 PDF 翻译成中文” 和 “翻译这份 PDF 成中文” 是同一个意图,但参数(文件路径)不同,缓存意图就能命中,缓存执行结果就不行。这一步不拆,月 3 想加缓存层时整个链路都得重写。

阶段 B(月 3-4):加 Embedding 缓存

进入这一阶段的触发条件:日 query 量稳定 > 5k,且 A 阶段日志里至少 30% 的 query 已经被规则或 LLM 自动打上了高置信度意图标签

为什么是这两个条件?

  • 日 query < 5k 时,缓存命中率会很低(毕竟用户说法千奇百怪),加这一层 ROI 不划算。
  • 标注覆盖 < 30% 时,缓存里塞的都是 LLM 标注的"伪标签",错误会扩散。
import time
import faiss
import numpy as np
from dataclasses import dataclass


@dataclass
class CachedClassification:
    query: str
    intent: Intent
    timestamp: float
    hit_count: int = 0


class EmbeddingCache:
    def __init__(self, embedding_dim: int = 384, max_size: int = 20000):
        self.index = faiss.IndexFlatIP(embedding_dim)
        self.entries: list[CachedClassification] = []
        self.max_size = max_size

    def search(self, query_embedding: np.ndarray, threshold: float = 0.92) -> Optional[Intent]:
        if self.index.ntotal == 0:
            return None
        scores, indices = self.index.search(query_embedding.reshape(1, -1), k=1)
        if scores[0][0] > threshold:
            entry = self.entries[indices[0][0]]
            entry.hit_count += 1
            return entry.intent
        return None

    def add(self, query: str, query_embedding: np.ndarray, intent: Intent) -> None:
        self.entries.append(CachedClassification(query=query, intent=intent, timestamp=time.time()))
        self.index.add(query_embedding.reshape(1, -1))
        if len(self.entries) > self.max_size:
            # 淘汰策略:LFU 优于 LRU,因为高频 query 才是缓存的本体价值
            victim = min(range(len(self.entries)), key=lambda i: self.entries[i].hit_count)
            self.index.remove_ids(np.array([victim]))
            del self.entries[victim]
# generated by hugo AI

月 1 团队常踩的坑:用 OpenAI text-embedding-3-small。每次缓存查询都要 API 调用,延迟 50-150ms,比缓存本身的 20ms 慢得多。月 3 上 embedding 缓存时一定要本地化,用 all-MiniLM-L6-v2(22M 参数,CPU 上 5ms)或 BGE-small。

这一阶段还有一件事可以同步做:把规则引擎从 5-10 条扩到 30-50 条。这时候你已经有 2 个月日志,能看清哪些 pattern 是真实存在的高频长尾。

阶段 C(月 6+):微调小模型 + 自进化闭环

进入这一阶段的触发条件:累计 query 量 > 50 万,标注覆盖 > 60%,且头部 100 个意图的分布在最近一个月波动 < 15%

第三个条件最容易被忽视。分布还在剧烈变化时去微调小模型,等于追着尾巴跑——刚训好模型,分布又变了。

到这一阶段,微调数据的构造可以走自动化:

A 阶段日志(已有意图标签)
    ├── LLM 高置信度标注(confidence > 0.9)→ 直接进训练集
    ├── 规则命中标注 → 直接进训练集
    └── 用户隐式反馈纠正过的 → 高权重进训练集
    DistilBERT / ModernBERT 微调
    线上 shadow mode 跑 1 周(不影响用户,对比 Stage 4 LLM 的结果)
    一致率 > 92% → 切流量到 Stage 3

自进化闭环的核心是让规则引擎自己长大。每当某个 LLM 分类结果连续 N 次(比如 10 次)出现在相似 query 上,就提议生成一条新规则,人工 review 后入库。

from collections import defaultdict


class RuleProposer:
    def __init__(self, threshold: int = 10):
        self.pattern_counter: defaultdict[tuple[str, Intent], int] = defaultdict(int)
        self.threshold = threshold

    def observe(self, query: str, llm_intent: Intent) -> Optional[str]:
        # 提取 query 的关键词模板(去除文件名、URL 等变量)
        template = self._extract_template(query)
        key = (template, llm_intent)
        self.pattern_counter[key] += 1

        if self.pattern_counter[key] >= self.threshold:
            return self._propose_rule(template, llm_intent)
        return None

    def _extract_template(self, query: str) -> str:
        # 实现细节:用 spaCy / jieba 提取动词 + 名词骨架
        # 例如 "打开 Chrome 浏览器" → "打开 ${APP}"
        return query  # 简化示意

    def _propose_rule(self, template: str, intent: Intent) -> str:
        return f"建议规则: {template}{intent.value}"
# generated by hugo AI

为什么是这个顺序:三个反直觉点

老王看完路线图问了三个问题,正好对应三个常见误区。

误区一:先上小模型,能省 LLM 钱

错。月 1-2 你的 LLM 调用量大概率不到 100 万次/月,按 GPT-4o $5/M token 算,意图分类那点 token,总成本不超过 $300/月。一个工程师一周的工资足够烧三个月 LLM。

但维护一个微调模型——数据清洗、训练、评估、版本管理、回滚机制——是一个长期占用 0.5 个工程师的活。月 1 团队 4 个人,掏不起这 12.5% 的人力。

误区二:缓存月 1 就能上,反正不复杂

也错。Embedding 缓存的命中率取决于 query 重复度。日活 8000、日 query 1.5 万的产品,前 100 个高频 query 大概只能覆盖 8-12% 的总流量,剩下 88% 是各种低频长尾。这种分布下缓存命中率不到 15%,加进来还要扛额外的 embedding 调用延迟,划不来。

要等用户量上去、行为模式收敛后,缓存才有 ROI。

误区三:先把规则铺满,能少调 LLM

最容易栽的一个。规则的真实成本不在写,而在维护。30 条规则的回归测试集合大概有 200-500 条 case,每改一条规则都要全跑一遍。你愿意为了省 30% 的 LLM 调用,多养一份持续维护的回归测试吗?

正确顺序是:先用 LLM 兜底,让数据告诉你哪些 pattern 真的高频且稳定,再把规则精准地加上去。

路由决策的两条经验(不分阶段,从月 1 就该做)

有两件事不分阶段,从第一天就要做对:

1. 上下文感知

同样的"打开那个文件",在 VS Code 里和 Finder 里指的不是同一个东西。任何阶段的 Router 都必须感知 active_app + 最近 5 次操作。这个上下文上线第一天就要采集,否则后面所有路由决策都是瞎子。

2. API 优先,GUI 兜底

Action selection:
    ├── 有原生 API?→ 直接调用(< 50ms,确定性 100%)
    ├── 有 URL Scheme / deep link?→ scheme 调用
    └── 都没有 → GUI 自动化(500-2000ms,非确定性)

UFO 团队的实践经验:能用 AppleScript / Win32 COM / D-Bus 完成的,绝不用 GUI 点击。这条原则月 1 就要立,因为如果你前期所有 tool 都是 GUI 自动化封装的,等到月 6 想换成 API 时,整个 tool 库都要重写

总结:这才是 1 个月团队的实施清单

月 1-2 必做:
  □ 规则引擎(5-10 条高置信度规则)
  □ 数据管道(QueryTrace schema + 隐式反馈)
  □ LLM 调用拆成 "意图分类 → Router → tool"
  □ 上下文采集(active_app + 操作历史)
  □ tool 抽象层(API 优先 GUI 兜底的接口)

月 3-4 触发条件达成后做:
  □ Embedding 缓存(本地模型,LFU 淘汰)
  □ 规则扩展到 30-50 条(基于真实日志)

月 6+ 触发条件达成后做:
  □ 微调小模型(DistilBERT / ModernBERT)
  □ Shadow mode 验证
  □ 规则自动提议机制
  □ 完整自进化闭环

四层漏斗是终态,不是起点。月 1 团队的核心决策不是"要不要上完整架构",而是"现在该把 1 个月后能加架构的地基打哪些"。地基打错(比如 LLM 调用没解耦、日志没结构化),后面每一阶段都要付重写代价。

老王的团队最后选了这条路径:第一周补完了 QueryTrace 日志,第二周拆了 LLM 调用,规则只写了 6 条。延迟从 P50 1.6 秒降到 1.2 秒(拆解后并行了一些操作),但更重要的是——他们月 3 想上小模型时,有干净的 30 万条标注数据可以直接用。

你的桌面 Agent 现在在哪个阶段?最大的瓶颈是延迟、数据,还是工程人力?欢迎留言讨论。


See also