这个 agent 是怎么搭的
具体到文件和参数:一张 LangGraph 状态图、三个平级工具、两个模型(一个写、一个判断)、 一套统一的证据编号,以及把它们串起来的 SSE 事件契约。下面是实际实现,不是示意图。
0组件总览
这一节是“零件清单”,给想看技术栈的人看。第一次看可以直接跳到第 1 节。
| 组件 | 技术选型 | 负责什么 | 代码 |
|---|---|---|---|
| Web 服务 | FastAPI + uvicorn | 认证、会话、SSE 流式输出、CSRF/CSP | app/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.steps | app/models.py |
1全图:节点与边
图在 build_graph() 里用 StateGraph(QAState) 声明,只有 5 个节点。
箭头上的字不是形容词,是代码里真实的判断条件。
怎么读:从 START 出发,每个箭头上的字是一个判断,按答案走对应的线。 方块里的大字是“它在干什么”,后面灰色小字是程序里的函数名(方便对着代码看)。
五个方块,用大白话说
| 图上的名字 | 它到底在干什么 | 什么时候走到这 | 发出什么信号 |
|---|---|---|---|
准备 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 的方块有两个:绿色的「回答」和蓝色的「闲聊」。 它们不是同一个模型的两种提示词,而是两个独立模块、两套提示词、两个预算。
app/agents/react.py所有财税问题走这里
- 3 个工具:财税法律知识库、联网搜索、计算沙箱
- 会循环:最多 3 轮,每轮可以一起要多个工具(总上限 4 次)
- 要你点“允许”:每个工具执行前弹确认条
- 产出引用:材料统一编号
[1][2],能对回来源 - 失败时:报错,并把已生成的一半保存下来
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信息怎么流动
同一个节点做两件事:往下传数据(状态),同时播给观众看(事件)。 两条通道互不干扰,所以“答得对”和“看得见”能同时成立。
工单上记什么
| 这一项 | 谁写的 | 内容(后面是程序里的字段名) |
|---|---|---|
| 你这句话 + 聊天记录 | 一开始 / 准备 | 你的问题、最近 10 条历史(按 6000 字符裁剪)question history |
| 判断结论 | 先判断 | 五个概率和最终结论verdict tax_prob |
| 答案和证据 | 回答 | 正文分段、证据列表parts citations |
| 过程记录 | 所有节点 | 每一步的时间戳,刷新页面还能回看,最多 30 步steps → meta.steps |
事件有哪些
| 事件名 | 里面装什么 | 观众看到什么 |
|---|---|---|
meta | conversation_id / assistant_message_id | 建会话、定位消息 |
status | {stage: thinking|generating} | 阶段提示 |
trace | classify / chitchat / llm / decision / calc / skip / budget / answer | 判断类事件:决策模型的概率、每次模型调用的首字/用时、某次工具调用为什么被跳过、预算何时用尽 |
thinking | {text} | 推理流(reasoning_content) |
tool | call / result + args、ok、duration_ms、detail | 工具执行与可展开输出 |
approval / approval_result | approval_id、tool、args、reason、expires_in / decision | 人工确认 |
references | items、query、queries、kinds | 证据池快照 |
delta / done / error | 增量文本 / 收尾统计 / 错误 | 回答与结束 |
4回答节点(agent)内部:怎么循环
核心只有一件事:边流式输出边决策。决策和生成是同一次模型调用——模型要么吐正文, 要么吐工具请求,不是“先判断再生成”两次请求。
一次模型调用花在哪:首字 / 总用时 / 重试
每次调用模型都会发一条 trace(phase=llm),带三个数:
首字延迟(多久吐出第一个字)、总用时、重试次数。
这三项分得清,才判断得出“慢”是模型排队、网络重试,还是我们自己在空转。
超时的口径也在这里定:读超时 180s 是“两个数据块之间最多等多久”,不是整次调用的上限; 整次调用另有 300s 硬上限。只有一个字都没吐时才会重试(最多 2 次), 一旦开始吐字就不再重试——否则用户会看到回答被从头重复一遍。
真实例子:沙箱 0.07s 就算完了,但收尾那次模型调用一共花了 176s—— 工具没花时间,时间全在等模型出字。没有这条 trace,界面上就只剩“一直转圈”。 另外,阶段提示会在第一段正文出现时从「分析问题、选择工具…」切成「正在写答案…」, 不要让它一直停在“选工具”。
预算(写死的上限,不是路由)
| 参数 | 默认 | 作用 |
|---|---|---|
TOOLS_MAX_ROUNDS | 3 | 最多几轮「决策 → 工具」;这是上限,用不到不会多跑 |
TOOLS_MAX_CALLS | 4 | 一次提问最多执行几个工具调用 |
KB_SEARCH_MAX_CALLS_PER_QUESTION | 2 | 知识库检索次数 |
WEB_SEARCH_MAX_CALLS_PER_QUESTION | 2 | 联网次数(不同关键词) |
TOOL_APPROVAL_TTL_SECONDS | 180 | 确认条倒计时,超时按跳过处理 |
AGENT_MAX_TOKENS | 1600 | 单轮生成上限;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 内部流水线
- 原句检索 → 按
RETRIEVAL_MIN_SCORE=0.30过滤(score is None视为通过) - 最高分 <
0.50或 0 命中 → 本地关键词压缩(去疑问词/口语词,不调模型)再查一次,两路按chunk_id合并取高分 - 问题里有多个主题(税种/社保/公积金/发票)且已有命中没覆盖 → 对缺失主题补查一次
- 取前
RETRIEVAL_TOP_K=6条,按CHUNK_MAX_CHARS=2000/CONTEXT_MAX_CHARS=12000装块 - 父块 3.5k~4k 字符,答案常在 2000 字后 → 用查询词字符 bigram 给段落打分,挑最相关段落
- 结果交给证据池统一编号
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)。 左边是状态在变什么,右边是同一时刻观众看到了什么——两列同一行是同一时刻。
meta)trace)trace)——确认增值税起征点的具体规定与适用范围approval)→ 4ms 后你点了允许tool)trace)——增值税法只说起征点由国务院规定,需确认 2026 年现行金额tool)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自己验证
这个页面讲的东西都可以在运行中的系统里对上:
- /debug:某条回答的完整时间线、工具入参出参、证据池、原始 meta
- 回答气泡上方的「执行过程」:同一个
meta.steps的折叠版,刷新后仍在 - 日志事件名:
clef.decided、agent.tools、tool.run、chat.completed - 测试:
.venv/bin/python -m pytest -q(98 个,全部离线,不访问网络) - 流程图:
.venv/bin/python scripts/graph_diagram.py→docs/agent-graph.mmd