Technical tour

这个 agent 是怎么搭的

具体到文件和参数:一张 LangGraph 状态图、三个平级工具、两个模型(一个写、一个判断)、 一套统一的证据编号,以及把它们串起来的 SSE 事件契约。下面是实际实现,不是示意图。

三十秒版本:你问一句 → 先让一个很便宜的小模型花半秒判断“这是财税问题吗” → 是的话,大模型自己决定要不要查资料(查法规库 / 联网 / 算数)→ 查到的材料统一编号再交回给它 → 它写答案,边写边显示。 图上那五个方块,就是这条路上的五个站点。

0组件总览

这一节是“零件清单”,给想看技术栈的人看。第一次看可以直接跳到第 1 节。

组件技术选型负责什么代码
Web 服务FastAPI + uvicorn认证、会话、SSE 流式输出、CSRF/CSPapp/main.py、app/api/
编排LangGraph 1.2 StateGraph一次问答的节点与条件边;节点自己发事件app/agents/graph.py
agent 循环自研(约 400 行)流式决策、工具审批、执行、轮次预算app/agents/react.py
闲聊 agent自研(约 120 行)零工具:只处理打招呼/道谢/问身份/无关问题app/agents/chitchat.py
知识库WeKnora POST /api/v1/knowledge-search法规原文召回(含关键词兜底、主题补查、片段打分)app/services/weknora.py
联网Tavily(可换 Serper / Bocha)最新公告、地方口径app/services/tools.py
代码执行bwrap 沙箱(本机或小主机)税额/缴费计算,断网、只读、限额app/services/sandbox.py
生成模型MiMo mimo-v2.6-flash写检索词、写代码、写正文app/services/llm.py
决策模型Cloudflare clef-flash五个是非判断,返回概率app/services/clef.py
状态存储SQLite(可换 Postgres)用户、会话、消息;执行步骤存 meta.stepsapp/models.py

1全图:节点与边

图在 build_graph() 里用 StateGraph(QAState) 声明,只有 5 个节点。 箭头上的字不是形容词,是代码里真实的判断条件。

怎么读:从 START 出发,每个箭头上的字是一个判断,按答案走对应的线。 方块里的大字是“它在干什么”,后面灰色小字是程序里的函数名(方便对着代码看)。

问答流程图:prepare 之后按是否寒暄分流,闲聊走独立 agent,其余经 classify 后进入工具 agent START 准备prepare 读历史、记下你这句话 短句寒暄 其余 闲聊chitchat 打招呼 / 无关 → 无工具 先判断classify 半秒问一句:这是财税问题吗 寒暄 / 明确无关 END 其余 回答agent 自己决定查不查资料 写出正文 END 一个字都没写 兜底refuse 什么都没查到 → 说不确定 END 箭头上的字是一个判断,按答案走对应的线 绿色 = 工具 agent(会查资料、会算);蓝色 = 闲聊 agent(零工具) 虚线框 END = 同一个出口,分开画只为避免连线交叉
财税问题走三个节点(准备 → 先判断 → 回答),闲聊只走两个(准备 → 闲聊); 观众看到的丰富过程全部发生在“回答”内部。

五个方块,用大白话说

图上的名字它到底在干什么什么时候走到这发出什么信号
准备 prepare 把会话建好、取出前面的聊天记录、记下你这句话 每次提问的第一步 meta
闲聊 chitchat 独立的闲聊 agent:没有任何工具,只调一次小规模模型回一两句话; 模型不可用时退回预设文案 短句寒暄,或判断模型认定“打招呼 / 明确无关(<0.10)” trace(chitchat)、trace(llm)、delta、done
先判断 classify 花 0.3~1.4 秒问一个小模型三件事:是财税问题吗?在打招呼吗?需要算数吗? 所有不是短句寒暄的提问 trace(classify)
回答 agent 大模型自己决定查不查资料、查几个,然后写答案;整页说的“循环”都在这个方块里 剩下的所有财税问题 thinking / tool / approval …(见下表)
兜底 refuse 什么也没查到、模型也没写字,就直说“不确定” 回答节点一个字都没输出 references(空)、delta、done

为什么只有一个节点在干活

如果把“查工具”也画成节点,图会变成 agent → tools → agent → tools → agent。 问题在于轮数是运行时才决定的:模型可能查 1 次、2 次,也可能一次都不查。画出来的时候 不知道要循环几轮,只能固定几个节点——等于把流程写死。

所以这里的分工是:图负责“这次走哪条路”,循环负责“这条路怎么走完”。

2两个 agent

图上标着 agent 的方块有两个:绿色的「回答」和蓝色的「闲聊」。 它们不是同一个模型的两种提示词,而是两个独立模块、两套提示词、两个预算。

① 工具 agent app/agents/react.py

所有财税问题走这里

  • 3 个工具:财税法律知识库、联网搜索、计算沙箱
  • 会循环:最多 3 轮,每轮可以一起要多个工具(总上限 4 次)
  • 要你点“允许”:每个工具执行前弹确认条
  • 产出引用:材料统一编号 [1][2],能对回来源
  • 失败时:报错,并把已生成的一半保存下来
② 闲聊 agent app/agents/chitchat.py

打招呼 / 道谢 / 问身份 / 明确无关的问题

  • 0 个工具:不查库、不联网、不计算
  • 不循环:只调一次模型,回一两句话(60 字以内)
  • 不用确认:没有可执行的动作
  • 没有引用:不进证据池,回答里不会有 [n]
  • 失败时:退回预设文案,这条路上永远不报错

为什么不在一个 agent 里做

在工具 agent 里加一句“如果是寒暄就正常聊天”也能跑,但代价很实在: 那一套系统提示词加上三个工具的 JSON Schema,一次就多几千 token; 而且模型看到工具会忍不住去查——问一句“你好”,它可能真的去检索“你好”。

拆开之后,闲聊只用一段两百字的提示词、600 token 上限、最近 4 条历史, 把话接住、把话题拉回财税,别的什么都不做。

两者共用什么

共用不共用
同一个 MiMo 客户端(app/services/llm.py) 提示词:财税系统提示 vs 闲聊小提示词
同一套 SSE 事件(trace / llm / delta / done) 预算:1600 token / 3 轮 vs 600 token / 1 次
同一条落库路径(_finalize,刷新后还能回放) 能力:3 个工具 + 审批 + 证据编号 vs 什么都没有

3信息怎么流动

同一个节点做两件事:往下传数据(状态),同时播给观众看(事件)。 两条通道互不干扰,所以“答得对”和“看得见”能同时成立。

两条通道:上面是节点之间传递的状态,下面是同时推送给浏览器的事件 状态 工单 节点返回一个字典 → 合并进总状态 → 下一个节点接着写 准备prepare 会话、历史 判断classify 判断结果 回答agent 正文、证据、步骤 保存 刷新还在 读你的话 → 写:财税?寒暄?要算数? 写:答案 + 证据 + 全过程 写进数据库 事件 旁路 建会话、开始计时 判断 0.8s 要工具(带理由) 等你确认 查到了 1.7s 正文流式 依据 6 条 · 完成 浏览器 事件不进“工单”(只留一份带时间戳的记录写进 meta.steps),所以过程再啰嗦也不会挤占模型的上下文。
上面那行是节点之间真正传的数据;下面那行是同一时刻发给浏览器的消息。

工单上记什么

这一项谁写的内容(后面是程序里的字段名)
你这句话 + 聊天记录一开始 / 准备你的问题、最近 10 条历史(按 6000 字符裁剪)
question history
判断结论先判断五个概率和最终结论
verdict tax_prob
答案和证据回答正文分段、证据列表
parts citations
过程记录所有节点每一步的时间戳,刷新页面还能回看,最多 30 步
steps → meta.steps

事件有哪些

事件名里面装什么观众看到什么
metaconversation_id / assistant_message_id建会话、定位消息
status{stage: thinking|generating}阶段提示
traceclassify / chitchat / llm / decision / calc / skip / budget / answer判断类事件:决策模型的概率、每次模型调用的首字/用时、某次工具调用为什么被跳过、预算何时用尽
thinking{text}推理流(reasoning_content)
toolcall / result + args、ok、duration_ms、detail工具执行与可展开输出
approval / approval_resultapproval_id、tool、args、reason、expires_in / decision人工确认
referencesitems、query、queries、kinds证据池快照
delta / done / error增量文本 / 收尾统计 / 错误回答与结束

4回答节点(agent)内部:怎么循环

核心只有一件事:边流式输出边决策。决策和生成是同一次模型调用——模型要么吐正文, 要么吐工具请求,不是“先判断再生成”两次请求。

agent 内部循环:组装消息、调模型,模型只出正文就结束,要工具则经过预算检查、人工确认、执行、编号回灌后回到下一次调模型 组装消息 系统提示 + 最近 6 条历史 + 你的问题 调一次模型 三个工具随手可调 思考过程实时推给前端 只展示,不改变走向 只输出正文 输出工具请求 边生成边显示 这一轮就结束,不再调工具 次数还够吗 重复的不查、用完的不查 等你确认 180 秒倒计时,人点允许 执行工具 知识库 / 联网 / 沙箱 编号后交回模型 变成 [1] [2] 的对话消息 跳过 未授权 带着结果进入下一轮 轮次用尽 轮次用完还没给出正文 不带工具再调一次,强制收尾,并提示不要输出 <tool_call>
三条出口(预算跳过 / 未授权 / 正常结果)都回到「调一次模型」。整张图最多跑 2 圈。

一次模型调用花在哪:首字 / 总用时 / 重试

每次调用模型都会发一条 trace(phase=llm),带三个数: 首字延迟(多久吐出第一个字)、总用时、重试次数。 这三项分得清,才判断得出“慢”是模型排队、网络重试,还是我们自己在空转。

超时的口径也在这里定:读超时 180s 是“两个数据块之间最多等多久”,不是整次调用的上限; 整次调用另有 300s 硬上限。只有一个字都没吐时才会重试(最多 2 次), 一旦开始吐字就不再重试——否则用户会看到回答被从头重复一遍。

真实例子:沙箱 0.07s 就算完了,但收尾那次模型调用一共花了 176s—— 工具没花时间,时间全在等模型出字。没有这条 trace,界面上就只剩“一直转圈”。 另外,阶段提示会在第一段正文出现时从「分析问题、选择工具…」切成「正在写答案…」, 不要让它一直停在“选工具”。

预算(写死的上限,不是路由)

参数默认作用
TOOLS_MAX_ROUNDS3最多几轮「决策 → 工具」;这是上限,用不到不会多跑
TOOLS_MAX_CALLS4一次提问最多执行几个工具调用
KB_SEARCH_MAX_CALLS_PER_QUESTION2知识库检索次数
WEB_SEARCH_MAX_CALLS_PER_QUESTION2联网次数(不同关键词)
TOOL_APPROVAL_TTL_SECONDS180确认条倒计时,超时按跳过处理
AGENT_MAX_TOKENS1600单轮生成上限;800 时模型会在计算题上直接心算

简化后的循环(这段是代码,可以跳过)

# app/agents/react.py(简化)
messages = [system_prompt, *history, user_question]

for round in range(max_rounds):
    calls, produced = [], False
    async for kind, text in llm.stream_events(messages, tools=schemas):
        if kind == "thinking":  yield "thinking", text
        if kind == "content":   produced = True; yield "delta", text
        if kind == "tool_calls": calls = json.loads(text)

    if not calls: break                    # 这一轮就是最终回答

    for call in calls[:remaining]:
        yield "approval"          # 等用户点“允许”
        result = await registry.run(name, args)
        evidence = pool.extend(result.citations)   # 统一编号
        messages.append({"role": "tool", "content": pool.render(evidence)})

if 没有正文:   # 轮次用尽/只调了工具
    yield from final_answer(messages)   # 不带 tools,强制收尾

四个容易踩的实现细节

流式 tool_calls 要自己拼

OpenAI 兼容接口把 tool_calls 拆成多个 delta(按 index), 需要累积 id/name/arguments 才算一个完整调用。

“我再去查一下”不能只说不做

模型可能在同一轮既输出旁白又发 tool_calls。此时旁白照常显示、工具照常执行, 旁白作为 assistant 消息的 content 回灌。

重复/超限:不执行,但要留痕

相同工具+参数、或超出次数上限,不执行、也不发 tool 事件(否则会覆盖上一条成功记录), 但会发一条 trace(skip) 说明原因,并回一条 role=tool 消息,否则下一轮请求不合法。

收尾不能再带工具

轮次用尽时用 tools=None 再调一次,并明确提示“不要输出 <tool_call>”, 否则模型会把工具调用当成正文吐出来。

5三个工具

三个工具同一层暴露给模型,没有“必须先查知识库”的顺序。选哪个、查几次,由模型按材料性质决定。 模型看不到工具时,也就不会调——没配置的依赖会直接从列表里消失。

工具模型要填什么返回什么进引用编号要确认实测耗时
kb_search 检索词 + 理由 法规原文片段,最多 6 条 是(kind=excerpt / summary) 是1.7s
web_search 关键词 + 理由 + 条数 网页标题、链接、摘要 是(kind=web,必须带 URL) 是2.5s
code_exec 一段 Python + 目的 标准输出、报错、产物文件 否——它是计算,不是资料 是0.05–0.7s

模型看到的 schema(真实内容,可以跳过)

{
  "name": "kb_search",
  "description": "检索财税知识库,返回法规原文片段。回答财税问题前应优先调用它;可以换关键词多次检索。",
  "parameters": {
    "type": "object",
    "properties": {
      "query":  { "type": "string", "description": "检索词:税种、政策名称、业务事项,尽量短" },
      "reason": { "type": "string", "description": "为什么需要检索,一句话,会展示给用户确认" }
    },
    "required": ["query", "reason"]
  }
}

reason 不是给模型看的,是给人看的:输入框上方确认条显示的就是这句话, 所以 schema 里明确写了“会展示给用户确认”。

kb_search 内部流水线

  1. 原句检索 → 按 RETRIEVAL_MIN_SCORE=0.30 过滤(score is None 视为通过)
  2. 最高分 < 0.50 或 0 命中 → 本地关键词压缩(去疑问词/口语词,不调模型)再查一次,两路按 chunk_id 合并取高分
  3. 问题里有多个主题(税种/社保/公积金/发票)且已有命中没覆盖 → 对缺失主题补查一次
  4. 取前 RETRIEVAL_TOP_K=6 条,按 CHUNK_MAX_CHARS=2000 / CONTEXT_MAX_CHARS=12000 装块
  5. 父块 3.5k~4k 字符,答案常在 2000 字后 → 用查询词字符 bigram 给段落打分,挑最相关段落
  6. 结果交给证据池统一编号

code_exec 的边界

bwrap --unshare-all:断网、只读挂载系统与 .venv、不挂载项目源码和 .env; timeout + prlimit + RSS 看门狗(不用 prlimit --as 当内存上限, 会把 numpy 卡死在启动阶段)。产物归属是进程内映射,重启后一律 404。

6两个模型怎么分工

模型干什么不干什么耗时实测
MiMo mimo-v2.6-flash写检索词、写代码、写候选答案、流式输出不做“是不是财税问题”这种布尔判断5~60s(推理模型,首字慢)
Cloudflare clef-flash一次请求返回 5 个概率:是否财税 / 是否打招呼 / 是否道谢 / 是否问身份 / 是否需要计算不生成任何文字,不产出工具参数0.3~1.4s

为什么不用大模型做这些判断:一个布尔判断也要等一次 15 秒的推理,而且会让“天气”这种问题也跑完整轮。

阈值:tax < 0.10 才算明确无关;寒暄类 ≥ 0.8 且 tax < 0.2 才交给闲聊 agent(不是预设文案,详见下一节);calc ≥ 0.6 只是给 agent 注入一句 “本题必须用 code_exec”的提示,不是硬门禁。

fail-open:超时(3s)、额度用尽、网络不通、返回格式异常,一律当作“没有判断”,继续走 agent——决策模型只是加速手段,不能成为故障点。

一个实测细节:MiMo 在绑定 tools 时默认用英文推理(0 个汉字)。系统提示词里明确“目标用户是中文用户、思考过程优先用中文”后, 复测两条问题都是 27~30 个汉字、0 个英文。

7证据与编号

知识库、网页进的是同一个编号池(app/services/evidence.py): 工具返回时分配 index,按 chunk_id 或 url 去重; 同一篇文档已经有原文片段时,它的自动摘要不再入池。

# 回灌给模型的 tool 消息(真实格式)
【知识库检索结果】
[1] 《中华人民共和国增值税法_20260101.txt》(第 3 页)(知识库原文)
小规模纳税人发生应税交易,销售额未达到起征点的,免征增值税……

【互联网搜索结果】
[3] 《国家税务总局政策法规库》(网页)
链接:https://fgk.chinatax.gov.cn/…

# 前端引用卡片按 kind 显示角标:原文 / 摘要 / 网页

编号在“证据进入上下文的那一刻”分配,所以编号顺序 = 模型读到材料的顺序。 沙箱结果不进编号池:它是计算,不是资料,提示词要求模型说“来自沙箱计算”。

8一次真实问答:两条轨道并排看

下面是一条真实记录(问:增值税起征点是多少,总耗时 46.1s)。 左边是状态在变什么,右边是同一时刻观众看到了什么——两列同一行是同一时刻。

时间
状态轨道:工单上多了什么
事件轨道:观众看到什么
0.0s
会话建好、读入 6 条历史、记下你的问题
建会话、开始计时(meta)
0.8s
判断结果:财税 0.956、不需要计算
小模型判定:财税 · 819ms · 546 tokens(trace)
5.9s
(还没有正文)
模型决定:查知识库(trace)——确认增值税起征点的具体规定与适用范围
5.9s
—
等你确认,输入框上方弹条(approval)→ 4ms 后你点了允许
7.6s
+2 条知识库原文,编号 1–2
知识库查到了 · 1711ms(tool)
19.0s
—
模型决定:再联网(trace)——增值税法只说起征点由国务院规定,需确认 2026 年现行金额
21.5s
+4 条网页,编号 3–6
联网查到了 4 条 · 2485ms,带链接(tool)
46.1s
正文 716 字、证据 6 条、步骤 9 条、轮次 2
正文写完 → 依据 6 条 → 结束(delta / references / done)

注意 19.0s 那一行:模型自己决定再联网,理由写在事件里。这不是代码里的规则, 是它读到“由国务院规定”之后,判断知识库还差一个当年的数字。

想看别的记录:打开任意一条回答下方的「执行记录」,或直接访问 /debug。

9失败与边界

情况处理
决策模型挂了fail-open:当作“没有判断”,直接走 agent,不拒答
知识库连不上kb_search 返回失败说明,模型用已有证据继续;不中断整轮
联网/沙箱失败同样降级为一条 tool 失败消息,绝不把错误当答案内容
用户点“跳过”回灌“未获授权”,模型必须说明没查,不能自己编结果
审批超时(180s)按跳过处理,确认条自动收起
模型生成中断已输出部分落库并标 meta.partial=true;流式已吐字后不重试,避免重复文本
模型超时读超时 180s(两个数据块之间最多等多久)、单次调用硬上限 300s; 一个字都没吐才会重试(最多 2 次),吐过字一律不重试,直接保留半截
模型传错参数名常见拼写(pplanation / querry / maxresults)自动纠正并记日志, 否则审批条上就看不到“为什么调这个工具”
没有证据也没有正文走 refuse,输出固定文案
上下文过长历史按条数(10)与字符(6000)双裁剪;证据按 12000 字符装块

10自己验证

这个页面讲的东西都可以在运行中的系统里对上:

← 回到对话 看一条真实执行记录 →