跳到主要内容

53 篇博文 含有标签「Engineering」

工程实践

查看所有标签

Embedding 进阶:Matryoshka 表示学习、量化向量与检索调优,把 RAG 的存储和延迟砍一个数量级

· 阅读需 10 分钟

前两篇分别聊了向量数据库选型(向量数据库选型对比)和检索链路的分块/重排序(RAG 检索优化深度拆解),但都把 Embedding 模型当成了黑盒——喂进去文本,吐出来一串浮点向量。这篇把盒子拆开,聚焦一个高级后端工程师做规模化 RAG 时迟早会撞上的问题:向量又大又多,存储和检索延迟怎么砍?

先算笔账。一个 100 万 chunk 的语料库,用 1536 维 float32 的 Embedding(比如 text-embedding-3-small),单是向量本体就是:

1,000,000 × 1536 × 4 字节 ≈ 6.1 GB

这还没算 HNSW 图索引的开销(通常再乘 1.2 到 1.5 倍)。规模涨到 1000 万 chunk,向量本体就 61GB,单机内存放不下,得上分布式。但真相是:绝大多数检索场景根本用不到 1536 维的全精度浮点。今天聊的三件事——对比学习怎么训出 Embedding、Matryoshka 怎么一个模型任意维度截断、量化怎么把向量压 8 到 32 倍——就是把这笔账砍下来的三条路。

GLM-5.3 与 Coding Agent 的安全能力:白盒审计、漏洞推理与证据分级

· 阅读需 8 分钟

2026 年 8 月,智谱发布 GLM-5.3,官方把这次更新的关键词定成两个:Coding 和 安全。前者不意外——上一代 GLM-5 的编程能力已经逼近闭源第一梯队;真正值得注意的是后者:模型发布前两周,智谱联合清华、南开及多家企业做了密集红队测试,累计发现漏洞 2404 个(初筛去重后),其中 1088 个中高危,覆盖系统内核、浏览器引擎、开源基础组件、互联网应用与协议等 220 个项目。在 CyberGym 白盒代码审查评测上,GLM-5.3 拿到 84.5%(上一代 5.2 为 77.2%),成为开源阵营里分数最高的安全模型。

为什么一个 Coding 模型要死磕安全?答案很直接:Coding Agent 已经从"给一段代码"进化到"读整个仓库、改文件、跑终端、装依赖、参与部署",拿到的权限越来越高,一次判断失误的后果被成倍放大。 当 AI 以机器速度写代码、操作系统时,安全必须跟上机器速度。这篇文章以 GLM-5.3 的实测为样本,拆解 Coding Agent 安全能力的三层结构:白盒审计、漏洞推理、证据分级——并讨论这套能力该怎么评测、怎么用。

LoRA/QLoRA 参数高效微调实战:低秩假设、NF4 双重量化与多 Adapter 生产部署

· 阅读需 8 分钟

全参微调(full fine-tuning)的成本账很吓人:训练时除了权重本身,还要存梯度,以及 Adam 优化器的一阶矩、二阶矩两个状态。一个 7B 模型,权重 bf16 约 14GB,梯度 14GB,Adam 状态约 28GB,再加上激活和中间结果,全参微调 7B 要 60GB 以上显存,70B 直接破 600GB。这不是普通工程师玩得起的。

于是有了参数高效微调(PEFT)三件套:Adapter、Prefix Tuning、LoRA。其中 LoRA(Low-Rank Adaptation,微软 2021)因为"训练省显存、推理时可合并、能热插拔多任务"成为事实标准;QLoRA(2023)进一步把底座量化到 4bit,让 65B 模型在单张 48GB 消费卡上做微调成为可能。本文不重复上一篇多 Agent 编排(多 Agent 协作架构),专注把 LoRA/QLoRA 这条线讲透到能直接上手。

多 Agent 协作架构:Orchestrator-Worker 之外,任务依赖图、上下文隔离与结果合并的工程实践

· 阅读需 10 分钟

单 Agent 的瓶颈很直观:上下文窗口有上限、一次只能专注一件事、越长的对话越容易跑偏。于是大家开始拆多 Agent——一个检索、一个写代码、一个质检、一个做安全审查。但真正上手做过的人会发现,多 Agent 协作的难点从来不是"让 Agent 会聊天",而是编排(Orchestration):任务怎么拆、谁先谁后、上下文怎么隔离、结果怎么合并、一个挂了怎么办。

本文不重复上一篇 A2A 协议(A2A 协议深挖),那解决的是"Agent 之间怎么发现和通信"的协议层;这篇解决的是"有了通信之后,你怎么组织它们干活"的工程层。协议是网卡,编排是大脑。

A2A 协议深挖:Agent 之间怎么协作?AgentCard、Task 状态机与 SSE 流式传输全解析

· 阅读需 9 分钟

很多人到现在还分不清 MCP 和 A2A 的区别,把它们当成一回事。一句话讲清:MCP 是给 Agent 接外设的 USB 接口,A2A 是给 Agent 联网的网卡。MCP 让一个 Agent 用统一方式调用工具、文件、数据库;A2A 让多个 Agent 之间互相发现、协商、委托任务。上篇拆解了 MCP 2026-07-28 新规范(MCP 规范解读),这篇聊它的姊妹协议 A2A。

RAG 检索优化深度拆解:分块策略到 Cross-Encoder 重排序,召回率提升的完整链路

· 阅读需 13 分钟

先抛一个反直觉的结论:RAG 效果不好的时候,九成问题出在检索,而不是生成。生成模型再强,检索漏掉的 chunk 永远不会出现在 context 里——"垃圾进,垃圾出"在 RAG 里是一条铁律。业界普遍共识是「检索质量决定 RAG 上限」:生成只能在上限之内修修补补,把答案写得通顺,但补不回没检索到的事实。

本文不聊"如何搭一个 hello-world RAG",而是拆解检索链路里真正决定召回率的三个环节——分块(Chunking)、检索融合(Hybrid Search)、重排序(Re-ranking)——每一环给出可运行代码、性能量级和生产踩坑记录。这是我在多个生产 RAG 系统里反复调过的地方。

LLM 量化深度拆解:4-bit 省 75% 显存,GPTQ / AWQ / GGUF / NF4 怎么选

· 阅读需 13 分钟

先摆一个反差数字:Llama-2-70B 的 FP16 权重约 140 GB,量化到 4-bit 后约 35 GB,压缩 75%,而它在 WikiText2 上的 perplexity 只上升 0.1~0.3。这意味着原本要两台 A100-80G 才装得下的模型,量化后一台就够,decode 速度反而更快——因为权重小到能塞进更大的显存余量里,KV cache 也能放得更长。

为什么「省显存」和「不掉精度」能同时成立?答案藏在 LLM 推理的真实瓶颈里。这篇拆四条主流量化路线(GPTQ / AWQ / GGUF / NF4)的原理、可运行代码和选型,写给要在生产里真刀真枪跑量化的人。

Graph RAG 深度解析:当知识图谱遇上 RAG

· 阅读需 10 分钟

把一份 50 页的技术报告丢给 RAG 系统,问它 "这份报告的核心结论是什么?"——传统的 chunk-embed-retrieve-generate 链路直接跪了。不是检索不准,是这个问题本身就无法被任何单个 chunk 回答。

这是 2024 年 Microsoft Research 提出 Graph RAG 的出发点。一年过去,这个方向已经从论文变成了实际可跑的工程方案。本文从架构层面拆解它到底在做什么、怎么做的、以及什么时候该用。


问题:Vector RAG 的盲区​

标准 RAG 流程的前提假设是:答案分布在少数几个 chunk 中。所以策略是 embed → top-k 检索 → 塞进 context → 生成。

一旦问题变成全局性的("总结全文"、"对比三个方案的优劣"、"找出贯穿始终的主题"),top-k 检索就失效了。一个直觉的做法是先把全文喂给 LLM——但 50 页报告远超 context window。就算模型支持 1M token,"大海捞针"的准确率也随 context 增长而骤降。

Graph RAG 的解法:先离线建图,再在线查询。把文档转化成知识图谱,用图算法提取结构,把全局问题转化成对图结构的查询。


架构总览​

Microsoft 的 Graph RAG 分两个阶段:

离线索引(Indexing)​

文档 → 切分 chunk → 实体/关系抽取 → 知识图谱 → 社区检测 → 社区摘要

每个步骤都跑 LLM,完全离线,不占查询延迟。

在线查询(Query)​

查询分两种模式:

  • Local Search:从图中提取与查询相关的实体邻居子图,适合事实型问题
  • Global Search:用社区摘要做 map-reduce,适合全局总结型问题

Global Search 是 Graph RAG 的核心差异化能力。下面重点拆它。


离线阶段:从文本到图谱​

Step 1:实体与关系抽取​

对每个 chunk 调 LLM,提取 (subject, predicate, object) 三元组:

# 伪代码:Graph RAG 的实体抽取 prompt 模式
EXTRACT_PROMPT = """从文本中提取所有实体和关系,返回 JSON:

{
"entities": [
{"name": "Microsoft", "type": "ORGANIZATION", "desc": "科技公司"}
],
"relationships": [
{"source": "Microsoft", "target": "Graph RAG", "desc": "提出者"}
]
}

文本:
{chunk_text}
"""

这一步是整个 pipeline 最贵的环节。一篇 100 页文档,按 300 token/chunk 切,会产生几百次 LLM 调用。Microsoft 的做法是并行跑,用 gpt-4o-mini 压低成本。

关键细节:同一个实体会在不同 chunk 中被提取多次("Microsoft"、"MSFT"、"微软"),所以下一步要做实体消歧。

Step 2:实体消歧(Entity Resolution)​

Graph RAG 没有用复杂的 entity linking,而是再调一次 LLM:

def resolve_entities(entity_groups: list[list[dict]]) -> dict:
"""同一类型的候选实体分组后,让 LLM 判断哪些是同一实体"""
prompt = f"""
以下实体中,哪些指向同一个真实世界实体?
返回分组结果。

候选实体:
{json.dumps(entity_groups, ensure_ascii=False)}
"""
# 返回合并后的实体映射

为什么用 LLM 而不是传统的字符串匹配?因为 "GPT-4" 和 "GPT4"、"gpt-4-turbo" 需要语义理解,简单的编辑距离不够。

消歧后,图里约 5-15% 的节点被合并,边也相应聚合。

Step 3:社区检测(Community Detection)​

这是 Graph RAG 最巧妙的一步。构建好实体-关系图后,用 Leiden 算法做社区发现:

import networkx as nx
import leidenalg
import igraph as ig

def detect_communities(G: nx.Graph) -> list[set[str]]:
"""Leiden 社区检测,返回多层社区结构"""
g_ig = ig.Graph.from_networkx(G)

# 多层社区检测——这是精髓
partition = leidenalg.find_partition(
g_ig,
leidenalg.ModularityVertexPartition,
n_iterations=-1 # 跑到收敛
)

# Leiden 天然支持层次化社区
# Level 0: 最细粒度的社区(几十个实体)
# Level 1: 合并后的中等社区
# Level 2: 最粗粒度的大主题社区
return partition

Leiden 优于 Louvain 的地方是它保证连通性——每个社区内部是连通的,不会出现碎片化社区。对于知识图谱这种天然聚团的结构,Leiden 跑出来的社区往往对应真实世界的主题:比如"模型架构"社区包含 Transformer、Attention、MLP 等实体,"训练方法"社区包含 SGD、AdamW、Learning Rate 等。

Step 4:社区摘要(Community Summarization)​

对每个社区,把其成员实体和关系序列化成文本,让 LLM 生成摘要:

def summarize_community(
community: list[str],
entities: dict,
relationships: list
) -> str:
"""为一个社区生成摘要"""
# 收集社区内的实体和关系
community_entities = [entities[e] for e in community]
community_rels = [
r for r in relationships
if r.source in community and r.target in community
]

prompt = f"""
以下是一个知识图谱社区的信息。请生成一个简洁的摘要,
描述这个社区的核心主题、关键实体和它们之间的关系。

社区实体:
{format_entities(community_entities)}

社区关系:
{format_relationships(community_rels)}

摘要:
"""
return call_llm(prompt)

这个摘要会在 Global Search 时被用到。摘要的质量直接决定最终回答的质量——摘要太泛,查询结果就是废话;摘要太细,又回到了 chunk-level 的问题。


在线查询:Map-Reduce 模式​

当用户问 "这份文档主要讨论了哪些技术方向?",Graph RAG 的 Global Search 流程:

1. 把用户 query 嵌入向量
2. 对所有社区摘要做向量检索,选出 top-K 个相关社区
3. 对每个社区的摘要,让 LLM 生成一个"局部答案"(map)
4. 把所有局部答案汇总,让 LLM 生成最终回答(reduce)
async def global_search(query: str, community_summaries: list[str]) -> str:
# Step 1: 检索相关社区
query_emb = embed(query)
relevant = top_k_by_similarity(query_emb, community_summaries, k=10)

# Step 2: Map — 每个社区生成局部答案
map_tasks = [
map_prompt(query, summary)
for summary in relevant
]
partial_answers = await asyncio.gather(*[call_llm(t) for t in map_tasks])

# Step 3: Reduce — 汇总成最终答案
final_answer = call_llm(reduce_prompt(query, partial_answers))
return final_answer

这和传统的 map-reduce summarization 思路一样,区别在于:输入不是随机采样的 chunk,而是按语义聚类好的社区摘要。每个社区摘要本身已经是一个凝练的信息单元,所以 map 阶段产生的噪音远小于直接对 chunk 做 map。

对于事实型问题("GPT-4 的 context window 是多少?"),走 Local Search:

1. 用 query 找到相关实体
2. 提取这些实体的邻居子图(1-2 hop)
3. 把子图信息 + 实体的原始文本属性 + 关系描述塞进 context
4. 生成回答

Local Search 的召回率显著高于纯向量检索,因为它利用了图的结构信息——"GPT-4" 和 "context window" 之间有一条 has_context_window 边,即使它们的文本描述中从来没有出现在同一个 chunk 里。


性能数据​

Microsoft 的论文在两个数据集上做了评测:

指标Naive RAGGraph RAG (Global)提升
Comprehensiveness2.873.97+38%
Diversity2.533.54+40%
Directness2.953.76+27%
LLM 调用次数1~12 (10 map + 1 reduce + 1 embed)—

数据来源:Microsoft Research, "From Local to Global: A Graph RAG Approach to Query-Focused Summarization", 2024

代价也很明显:

  • 索引成本:一篇 100 页文档,约 $4-8(gpt-4o-mini),$50-100(gpt-4o)
  • 索引时间:串行跑需要 30-60 分钟,并行可以压到 5-10 分钟
  • 查询延迟:Global Search 需要 10+ 次 LLM 调用,单次查询 10-30 秒

什么时候该用 Graph RAG?

  • ✅ 需要对大文档集做全局总结、主题分析、趋势发现
  • ✅ 文档之间有密集的实体关联(科研文献、法律文书、技术报告)
  • ✅ 索引可以离线跑,查询延迟可以接受 10-30 秒
  • ❌ 简单的 FAQ 或事实型问答——标准 RAG 更快更便宜
  • ❌ 需要毫秒级响应的实时场景
  • ❌ 文档实体关系稀疏(小说、散文)

工程落地要点​

1. 实体抽取的 Prompt 设计​

实体类型的定义直接影响图的质量。太少,图就稀疏;太多,消歧压力大。建议:

# 实际可用的实体类型(不是越多越好)
ENTITY_TYPES = [
"PERSON", # 人物
"ORGANIZATION", # 组织/公司
"TECHNOLOGY", # 技术/框架/模型
"CONCEPT", # 抽象概念
"METRIC", # 指标/数值
"EVENT", # 事件
]

控制在 5-8 个类型,每个类型有明确的边界定义。

2. Chunk 大小不是越小越好​

实体抽取需要一个 chunk 内包含足够的上下文才能准确识别关系。Graph RAG 的推荐是 600-1200 token/chunk,比传统 RAG 的 256-512 更大。

3. 增量更新是个坑​

图结构让增量更新变得复杂——新增一个实体可能改变社区结构,导致需要重新计算社区检测和摘要。目前 Graph RAG 的设计假设是"全量重建",对于频繁更新的文档集不友好。一个折中方案是分区索引(按文档或时间段分区),只重建变更的分区。

4. 社区层级的选择​

Leiden 产出的是多层社区结构。Global Search 用哪一层?

  • 层级太高(Level 0):摘要太多,map-reduce 成本高
  • 层级太低(Level 2/3):摘要太粗糙,信息丢失

经验法则是选 实体数中位数在 20-100 的那一层。太小没聚合效果,太大每个摘要就失去主题聚焦性了。


与 LightRAG 等变体的对比​

2024 年底出现了几个 Graph RAG 的变体,最值得注意的是 LightRAG:

Graph RAG (Microsoft)LightRAG
社区检测Leiden(层级化)不显式做,用图遍历替代
查询模式Global + LocalNaive / Local / Global / Hybrid 四种
索引时间慢(多次 LLM 调社区摘要)快(跳过社区摘要)
实体消歧LLM-based向量相似度 + LLM 辅助
适用场景大规模静态文档集快速迭代、中小规模

LightRAG 本质上是用检索时的图遍历(BFS/DFS)替代了离线社区摘要,牺牲了一些全局理解能力换来了更高的索引速度。


总结​

Graph RAG 解决的不是检索精度问题,而是检索范式问题——从 "找到包含答案的 chunk" 变成 "理解文档的全局结构"。这个思路的价值远超 RAG 本身:它证明了 LLM + 图结构 是一种比 LLM + 向量更丰富的知识表示方式。

对于后端工程师来说,Graph RAG 的技术栈其实很熟悉:图数据库(Neo4j/Neptune)、图算法(Leiden/Louvain)、Map-Reduce 模式——加上 LLM 作为"智能提取器"。你需要关注的不是这些组件本身,而是它们组合后产生的协同效应。

下一步可以深挖的方向:

  • 用 Graph RAG 做代码库理解(实体 = 函数/类/模块,关系 = 调用/继承/依赖)
  • 多跳推理:不只查 1-2 hop 邻居,而是让 LLM 沿图做多步推理
  • 图 + 向量双索引:局部事实走图,语义相似走向量,路由分发

参考​

  • Edge, D., et al. "From Local to Global: A Graph RAG Approach to Query-Focused Summarization." arXiv:2404.16130, 2024.
  • Traag, V.A., et al. "From Louvain to Leiden: guaranteeing well-connected communities." Scientific Reports, 2019.
  • LightRAG: https://github.com/HKUDS/LightRAG

拆解 Function Calling:大模型工具调用的全链路与坑

· 阅读需 10 分钟

Function Calling 是 LLM 走出聊天框的关键能力。从表面看就是调个 API、传几个 JSON schema 的事,但把这条路从头走到尾,坑全在下水道里。

这篇文章不聊"怎么用",聊"里面长什么样"。以 OpenAI 和 Anthropic 为主线,穿插自己踩过的坑。


全链路:从定义到执行​

一条工具调用链路分 6 个阶段:

tool definitions → prompt assembly → token generation → parsing → execution → result injection

每个阶段都可能翻车。

阶段 1:Tool Definition → System Prompt Injection​

你传的 tool schema 不是直接发给模型的——各家会先把它"翻译"成某种文本格式塞进 system prompt 或特殊 token 段。

OpenAI 的做法:把你的 JSON schema 转成一个类似 TypeScript 函数签名的文本描述,拼进 chat template:

// OpenAI 内部近似格式(逆向推测,非官方文档)
namespace functions {
// 查询天气
type get_weather = (_: {
city: string, // 城市名称,如 "Beijing"
unit?: "celsius" | "fahrenheit"
}) => any;
}

Anthropic 则直接把 tool definition 作为 XML 块塞进 system prompt:

<tools>
<tool_description>
<tool_name>get_weather</tool_name>
<description>查询指定城市的天气</description>
<parameters>
<parameter>
<name>city</name>
<type>string</type>
<description>城市名称,如 "Beijing"</description>
</parameter>
</parameters>
</tool_description>
</tools>

一个关键差异:OpenAI 用特殊 token 标记 tool call 边界(<tool_call> / </tool_call> 之类),模型被训练成在这些 token 之间输出结构化 JSON。Anthropic 的输出则是一段 XML 文本,靠后处理解析。

这意味着如果你自己搭推理服务(vLLM、TGI 等),tool calling 能否正常工作强依赖 chat template 是否完整。缺了 tool-related 的 special token 定义,模型要么完全不调工具,要么输出格式乱七八糟。

坑 1:Chat template 不完整

用 vLLM 部署开源模型时,很多人直接用默认的 --chat-template,结果 tool calling 全挂。正确做法:

# 从模型的 tokenizer_config.json 里找完整模板
python -c "from transformers import AutoTokenizer; \
t = AutoTokenizer.from_pretrained('Qwen/Qwen2.5-7B-Instruct'); \
print(t.chat_template[:2000])"

然后把完整模板传给 vLLM:

vllm serve Qwen/Qwen2.5-7B-Instruct \
--tool-call-parser hermes \
--chat-template /path/to/chat_template.jinja

阶段 2:流式输出的结构性挑战​

这是第二个大坑。前向推理的 token 序列是单向无回看的——模型不知道它"已经输出了多少个 {"。

JSON 的结构约束(括号匹配、字符串引号闭合)只能靠模型自己"猜"。

当你用 streaming 模式时,问题更严重。你没法等完整 JSON 到了再 parse——用户要实时看到 tool call 的 UI 反馈。

各家方案:

提供商流式策略
OpenAI帧级 parse:每当有新的 arguments chunk 到达,追加到 buffer 并做增量 JSON parse
Anthropic同样增量 parse,但用 XML 格式(方括号匹配问题换成标签闭合问题)
Hermes / Nous输出纯文本 tool call 格式(<tool_call>...</tool_call>),parse 更简单但依赖模型遵守格式

坑 2:增量 JSON parse 不是 JSON.parse

你不能直接用 JSON.parse() 解析流式 tool call argument——那不完整。需要一个增量 JSON parser。

一个最简单的实现(生产级自己写大概率有 bug,推荐用 partial-json-parser 或 json-repair 库):

import re
from typing import Iterator

def parse_streaming_tool_calls(chunks: Iterator[str]) -> list[dict]:
"""简化的增量 tool call 解析"""
buffer = ""
calls = {}

for chunk in chunks:
buffer += chunk
# 尝试匹配 tool call 边界
# 各家格式不同,这里是 Anthropic 的 XML 风格
pattern = r'<function_calls>\s*(.*?)\s*</function_calls>'
match = re.search(pattern, buffer, re.DOTALL)

if match:
# 解析内部的 <invoke> 块
for invoke in re.finditer(
r'<invoke name="(\w+)">\s*<parameter[^>]*>(.*?)</parameter>\s*</invoke>',
match.group(1), re.DOTALL
):
name = invoke.group(1)
params = invoke.group(2)
calls[name] = params

return calls

现实是,生产环境需要处理:

  • 跨 chunk 的 token 边界:一个 token 可能被切成两半跨两个 chunk
  • partial JSON:{"city": "Bei 过了一个 chunk 才收到 jing"}
  • 多 tool call 并行:index 字段追踪到哪个 tool call
  • argument 重传:OpenAI 有时同一个 tool call 的 argument 会发两遍

阶段 3:Tool Choice——强制调用 vs 自由选择​

tool_choice: "required" 强制模型必须调工具,这条看似简单实则暗坑无数。

OpenAI 的做法:tool_choice 直接影响 logit bias——给 tool-call-start 的 token 一个巨大的正 bias,确保模型第一个 token 就是 <tool_call>。

坑 3:强制调用时模型瞎编参数

当你设 tool_choice: "required" 但用户的输入跟任何工具都没关系时,模型会硬编一个 tool call。

User: "讲个笑话"
Model: get_weather(city="笑话") // 🤦

解决方案:把"不调用工具"也暴露为一个工具选项:

{
"type": "function",
"function": {
"name": "no_tool_needed",
"description": "当用户请求不需要调用任何工具时使用",
"parameters": {
"type": "object",
"properties": {
"response": {
"type": "string",
"description": "直接回答用户"
}
}
}
}
}

或者——更省 token 的做法——不用 required,用 prompt engineering 在 system prompt 里约束:

你必须对每个用户请求调用至少一个工具。如果没有任何工具匹配,调用 no_tool 并解释原因。

Anthropic 的处理:tool_choice 可以指定到具体 tool name,也可以用 "any" / "auto"。不像 OpenAI 那样用 logit bias 硬搞,而是用 system prompt 引导。副作用是 Anthropic 的 "any" 有时不生效,模型仍然可能不调工具。

阶段 4:Tool Result 注入与上下文管理​

工具执行完毕后,结果需要注回对话上下文。这里有两个关键问题。

Token 爆炸

一个 API 返回 3000 行 JSON 你全塞回去,下一次请求直接爆 context。该截断的不截断,模型会"记住"垃圾信息;截断了又可能丢掉关键数据。

def truncate_tool_result(result: str, max_chars: int = 8000) -> str:
"""截断 tool result 并加上警告"""
if len(result) <= max_chars:
return result

truncated = result[:max_chars]
return (
f"{truncated}\n\n"
f"[结果已截断。原始长度: {len(result)} 字符,"
f"截断后: {max_chars} 字符。"
f"如果需要的部分被截断,请缩小查询范围。]"
)

并行调用顺序

OpenAI 支持并行 tool call(多个 tool call 在同一个 response 里),但它们是同时执行还是顺序执行取决于你的业务逻辑。

有依赖关系时必须串行:

async def execute_tool_calls(tool_calls: list, dependency_graph: dict = None):
"""按依赖关系执行 tool call"""
results = {}

if not dependency_graph:
# 无依赖——全部并行
tasks = [execute_one(tc) for tc in tool_calls]
for tc, result in zip(tool_calls, await asyncio.gather(*tasks)):
results[tc.id] = result
return results

# 有依赖——拓扑排序后顺序执行
for tier in topological_sort(dependency_graph):
tasks = [execute_one(tc) for tc in tier]
tier_results = await asyncio.gather(*tasks)
for tc, result in zip(tier, tier_results):
results[tc.id] = result

return results

开源模型的 Tool Calling 解析器对比​

不同开源模型用不同的 tool call 格式,全靠 parser 识别。以下是常见格式对比:

模型Tool Call 格式Parser
Nous Hermes<tool_call>{"name": "...", "arguments": {...}}</tool_call>hermes
Mistral[TOOL_CALLS] [{"name": "...", "arguments": {...}}]mistral
Llama 3.x通过 python_tag 特殊 token 输出 JSON 格式llama3_json
Qwen 2.5<tool_call>\n{"name": "...", "arguments": {...}}\n</tool_call>hermes (兼容)
DeepSeek类似 Hermes / Qwen 的 XML 包裹 JSON 格式deepseek

坑 4:Parser 不匹配

vLLM 的 --tool-call-parser 设错了,模型照样跑、不报错,但 tool call 解析永远返回空。排查方法:

# 查看模型实际输出的 raw text
from vllm import LLM

llm = LLM(model="microsoft/Phi-3.5-mini-instruct")
output = llm.generate([{"role": "user", "content": "..."}])
print(repr(output[0].outputs[0].text)) # 看 raw token 输出

如果输出格式和 parser 不匹配,要么换 parser,要么换模型。


生产环境踩坑清单​

坑 5:Tool description 太长 → 模型"忘记"调用​

每个 tool definition 都消耗 input token。5 个 tool 各 200 token 的 description,就是 1000 token。如果总 tool 数多到几十个,单是 tool schema 就占一半 context。

我的做法:分层 tool selection。先用一个轻量分类模型判断意图,只传相关的 3-5 个 tool:

TOOL_GROUPS = {
"database": ["query_db", "insert_db", "update_schema"],
"file_system": ["read_file", "write_file", "list_dir"],
"api": ["http_get", "http_post", "http_put"],
"agent": ["delegate_task", "spawn_agent", "kill_agent"],
}

def select_tools(user_intent: str, all_tools: list) -> list:
"""根据意图选相关 tool group"""
intent = classify_intent(user_intent) # 轻量分类
return [t for t in all_tools if t.group in TOOL_GROUPS.get(intent, [])]

坑 6:无限递归调用​

模型调 tool → 返回结果 → 模型觉得不够 → 再调 tool → 再调 → ... 直到 token 耗尽。

硬性终止条件:

MAX_TOOL_ROUNDS = 10

for round_num in range(MAX_TOOL_ROUNDS):
response = await client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)

if not response.choices[0].message.tool_calls:
break # 模型自由回答了

for tc in response.choices[0].message.tool_calls:
result = await execute_tool(tc)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result
})
else:
# 达到最大轮次,强制终止
messages.append({
"role": "user",
"content": "已达到最大工具调用次数限制,请基于已获取的信息总结回答。"
})

坑 7:Tool 执行超时不反馈​

工具调了 HTTP API,API 挂了,30 秒超时。你不处理超时,模型就在那等着,用户也在那等着。

import asyncio

async def execute_with_timeout(tool_call, timeout: float = 30.0):
try:
result = await asyncio.wait_for(execute_tool(tool_call), timeout=timeout)
return result
except asyncio.TimeoutError:
return json.dumps({
"error": "timeout",
"message": f"工具 {tool_call.function.name} 执行超时({timeout}s),请换一种方式查询。"
})

把错误作为 tool result 返回,让模型自己决定下一步——比直接抛异常优雅得多。


一条关于 Anthropic Computer Use 的观察​

Anthropic 的 computer_use 工具——截图→点击→截图→输入→截图——本质上是把 tool calling 推到了极致。每个操作都是 tool call,结果是一张新截图。6-8 轮 tool call 完成一页操作,token 消耗惊人(单次操作加一张高分辨率截图 ≈ 数千 token)。

这暴露了 tool calling 架构的一个根本限制:当前的设计要求每次工具执行后都把结果全量塞回上下文。对于长链 tool call 场景(浏览器操作、代码生成)这是无效循环。未来的方向可能是 tool result 的状态压缩——不是传完整截图,而是传一个"页面变了什么"的 diff。


总结​

Function Calling 看起来只是一层薄薄的 API wrapper,但把它拆开来看,每个阶段都有暗坑:

  1. Tool definition 注入:依赖 chat template,缺特殊 token 直接挂
  2. 流式解析:增量 JSON parse,不是 JSON.parse
  3. 强制调用:模型会编造参数,需要兜底 no-op tool
  4. 结果注入:截断策略决定信息质量
  5. Parser 匹配:开源模型格式各异,parser 不对不报错但无结果
  6. 递归控制:必须有硬性终止条件
  7. 超时处理:把错误作为 tool result 返回,而非直接抛异常

把这些坑填了,Function Calling 才算从 Demo 级进化到生产级。