AI实训第二周:B4大语言模型决策组件五大维度进阶实操
作者:xingwangzhe
本文链接:https://xingwangzhe.fun/posts/ai-training-b4-llm-week2/[1]
本文采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议[2]进行许可。
前置声明:本图文存在AI辅助整理
前文Day4 Proposal[3]中我详细分析了 B4 LLM 决策模块的设计方案
本文在 Proposal的架构基础上,逐一攻克五个进阶要求,让一个 4B 小模型跑出了远超预期的工具调用能力。
先回顾一下 B4 在 Agent 系统中的位置:
B4 是系统唯一的"大脑"——所有工具调用的决策都在这里发生。Proposal 中设计的基础版已经实现了 ReAct 单步调用(choose exactly one tool),进阶要求需要在此基础上做五件事:
下面按顺序逐一拆解。
在 Day4 Proposal 中我提到:基础版有三个核心约束——prompt 要求"choose exactly one tool"、Mock 只返回一个tool_call、工具调用每次只有一轮闭环。进阶要求就是逐个打破这些约束。
五个改动从简单到复杂,存在一定的依赖关系:
说实话,这五个里面最让我意外的是第 4 个——传参方式对比。原以为 builtin 是"正道",prompt 注入是"野路子",结果跑出来的数据令人意外。这个后面细说。
Day4 Proposal 的基础版有两个硬性限制:
这意味着用户说"帮我读文件、顺便算个数、再搜个东西"时,模型被 prompt 约束,只能一件一件来——三轮 ReAct 循环,每轮一次推理,效率低得累死。
改动集中在两个核心函数:_build_prompt_messages和_mock_generate。
Schema B 示例从 1 个tool_call扩展到 2 个——file_reader+calculator——给模型一个"多工具调用"的具体范例。提示语从 "choose exactly one" 改为 "zero, one, or multiple"。
说实话,4B 参数的模型,能理解"并发调用多个工具"这个概念吗?但 Qwen3.5-4B 的表现让我吃惊。后面的验证数据会证明这一点。
不再假设只有一条 ToolMessage,而是用reversed动态统计连续的数量,在 prompt 中明确告知模型"你刚才调了 N 个工具,结果都在这里了,请用 schema A 回答"。这个小改动的价值在于——它把元信息(这次发起了几个并发调用)传递给了模型,减少了模型"忘掉自己刚才做了什么"的概率。
Mock 模式现在不再只看最后一条 ToolMessage,而是遍历所有、检查每条的状态、汇总所有结果。失败策略也变了:任一失败则整体失败(fail-fast),而不是只看最后一条。
用真实 Qwen3.5-4B 测试,分三个梯度验证:
最高测试到5 并发 tool_calls,Mock 模式完美通过。最具说服力的是真实模型的 3 并发测试:
输入: "帮我做三件事:1) 阅读 docs/agent_intro.txt;2) 计算 3.14 * 5;3) 搜索包含'Agent'的文件。"
模型输出 tool_calls:
三个工具结果全部返回后,模型合并输出:
status: success[OK]
说实话,看到这个结果的时候我乐了——就改了三行 prompt,一个 4B 的小模型就能从"单步调用"进化到"三并发"。不是说 4B 模型能力不够,而是prompt 给它的"自由度"决定了它的行为边界。
在 Day4 Proposal 中,基础版 B4 遵循的是ReAct(Reasoning + Acting)范式:
每一步都要过一次 LLM——做一次推理、调一个工具、看结果、再推理……这在简单任务上没问题,但复杂任务会导致多轮调用、token 消耗翻倍、延迟累积。
Plan-and-Execute的思路完全不同:
模型先"通盘考虑"生成一个有序步骤计划,然后逐步执行。类比一下:ReAct 是边想边做,Plan-and-Execute 是先列清单再逐项打勾。
新增plan_execute模式,分两个阶段:
其中_parse_plan_output的兼容性设计值得一提——它支持三种输入格式:
这个灰度兼容策略的灵感直接来自 Day4 Proposal 的三层解析设计——永远不要假设模型的输出格式是完美的。
步骤执行后,模型合并结果输出最终回答:
已完成任务:
Plan-and-Execute 的两阶段 prompt 设计是我觉得整个 B4 进阶中最"优雅"的设计。计划生成阶段的 prompt 需要给模型充分的自由度和结构化的计划格式引导;而步骤执行阶段的 prompt 则需要约束模型"你已经有了计划,现在按计划行事,别乱改"。两个阶段的目标不同,prompt 自然也不同。
Day4 Proposal 中设计了_MODEL_CACHE全局缓存字典,缓存键包含:
cache_key=hash(model_path∥tokenizer_path∥dtype∥device_map∥max_memory)cache\_key = hash(model\_path \parallel tokenizer\_path \parallel dtype \parallel device\_map \parallel max\_memory)cache_key=hash(model_path∥tokenizer_path∥dtype∥device_map∥max_memory)
这个设计的精妙之处在于——它天然支持多模型。只要缓存键不同,就会自动触发 cache miss 并加载新模型。切换任何配置参数都会自动触发重新加载。
所以进阶要求"支持模型切换"的改动量其实非常小——只需要一个 name resolver,把用户选择的 profile name 映射到对应的配置参数。
在model.yaml中新增models节,定义多个命名 profile:
同一个物理模型Qwen3.5-4B可以有不同的生成参数配置——standard 模式max_new_tokens=1024,fast 模式max_new_tokens=512。未来接入不同路径的模型(比如 Qwen3.5-7B)也能直接复用这套机制。
CLI 加--model_name可选参数。未知 model_name 会列出所有可用选项并报错——用户体验细节不能少。
说实话,模型切换是整个 B4 进阶中改动量最小但设计感最强的一个。_MODEL_CACHE在 Proposal 阶段就考虑到了扩展性,现在只是加了一个薄薄的 name resolver。好架构的特点是:当新需求来临时,改动集中在最薄的接口层。
这是五个进阶要求中最有意思的一个——把 tools_schema注入 prompt 文本(prompt 注入)vs 通过tokenizer.apply_chat_template(tools=...)原生传入(builtin 内置),两种方式谁更可靠?
Day4 Proposal 中的双保险 prompt 策略(format_instruction+envelope_reminder)和三层降级解析,全部基于一个前提:模型输出的是 JSON 格式。
于是我做了一个对比实验:
prompt_json 模式输出(符合预期 [OK]):
builtin 模式输出(意外):
Qwen3.5-4B 的内置工具调用输出的不是 JSON,而是原生 XML 格式!
而我们 Day4 Proposal 中精心设计的三层解析策略——json.loads→raw_decode→tool_calls片段提取——三层全部依赖 JSON。XML 输入进去,走到哪一层都是JSONDecodeError。
对比直观:
prompt 注入方式在当前 JSON 解析架构下更可靠。内置传参虽然理论上更"优雅",且节省 ~500 token,但 Qwen3.5-4B 原生的工具调用输出是 XML 格式,与 JSON 解析器完全不兼容。要使用 builtin,需要单独实现 XML 解析器并适配整个三层解析链路。
这个结论的价值在于:它用数据说明了为什么 prompt 工程在小模型场景下比"原生能力"更可控。不是"prompt 注入是野路子",而是——当你不能控制模型的输出格式时,控制 prompt 就是你唯一能做的事。
说实话,看到 builtin 输出 XML 的那一刻,我先是愣了一下,然后忍不住笑了。原以为 builtin 是"正道"、prompt 注入是"野路子",结果正道走不通,野路子反而稳如老狗。这也是这次实训给我的感悟:不要迷信"原生能力",在小模型上,你能掌控的东西才是你真正拥有的东西。
为了量化评估,我写了一个b4_batch_benchmark.py,构造6 个不同场景的测试样例,每个样例跑两种传参方式,统计成功率和延迟:
覆盖了五种工具类型 + 直接回答 + 并发场景,基本涵盖了 Agent 系统常见的工具调用形态。
rate=nsuccessntotal=56×100%≈83.3%rate = \frac{n_{success}}{n_{total}} = \frac{5}{6} \times 100\% \approx 83.3\%rate= n total n success = 6 5 ×100%≈83.3%
tˉlatency=1n∑i=1nti=1574.5+9222.5+3592.3+7361.7+769.7+1724.86≈4040.9ms\bar{t}_{latency} = \frac{1}{n}\sum_{i=1}^{n} t_i = \frac{1574.5 + 9222.5 + 3592.3 + 7361.7 + 769.7 + 1724.8}{6} \approx 4040.9\text{ms} t ˉ latency = n 1 ∑ i=1 n t i = 6 1574.5+9222.5+3592.3+7361.7+769.7+1724.8 ≈4040.9ms
case_calculator的表达式3.14 * 5 - 2.5比较长,模型同时输出了content和tool_calls,触发了 Day4 Proposal 中设计的四层互斥约束:
回顾 Day4 Proposal 中的互斥规则:
content 和 tool_calls二者必有其一,不可同时存在或同时为空。
小模型在处理长表达式时容易"犹豫"——既想自己算(输出 content),又想调用工具(输出 tool_calls),结果两边都做了,触发了互斥校验。
解决方案有两种思路:
不需要逐样例分析了——6 个样例全部因为 Qwen 原生 XML 输出格式与 JSON 解析器不兼容而失败。不过值得注意的是 builtin 模式平均延迟更低(3173.4ms vs 4040.9ms),因为不需要解析 500 token 的 tools_schema,prompt 更短推理更快——只是解析全挂了,再快也没用。
说实话,如果没有这个批量测试脚本,我可能永远不会发现case_calculator那个互斥失败的 corner case——它是那种"跑一次没事、跑十次才碰到一次"的间歇性问题。批量测试是唯一的真相