技术·Agent From Zero:从一次模型调用到可靠 Agent · 第 7 篇·2026-09-18·13 分钟

第六篇:查不到商品时,模型会不会编一个数字

上一篇的实验中,库存工具返回了真实记录,模型据此回答“A100 还有 20 件”。这次我们反过来测试:如果工具明确表示“没有查到”,模型会直接说明没有数据,还是会给出一个看起来合理的库存数字?

大模型在没有事实依据时,仍然生成具体而肯定的答案,这种现象叫作幻觉(hallucination)。如果工具没有查到 Z999,模型却回答“还剩 12 件”,就是本篇要检查的幻觉。

这一篇不增加业务功能,只验证两件事:

  1. 1.程序能不能把“没有查到”正确地传给模型。
  2. 2.模型实际收到这个结果后会怎样回答。

文章对应代码仓库 agent-from-zero 的 v0.1.3-hallucination-guard。可以切换到这个 tag,对照 src/agent/agent.py 与 tests/test_agent.py 阅读。

先确认程序能正确传递“没有查到”

第四篇里的 get_inventory() 使用 products.get(product_id) 查询商品。因此,get_inventory("Z999") 不会抛出异常,而是返回 None。这表示查询正常完成,只是没有找到对应商品。

接下来,ask() 要把这个结果放进 role: "tool" 消息,再交给模型。如果转换错误,模型收到的就不再是工具真正返回的内容。下面两个测试专门检查这段程序逻辑:

python
def test_none_result_is_serialized_as_json_null_in_the_tool_message(monkeypatch):
    tool_call = _tool_call("call_1", "get_inventory", {"product_id": "Z999"})
    first_response = _response(content=None, tool_calls=[tool_call])
    second_response = _response(
        content="I couldn't find any records for Z999.",
        tool_calls=None,
    )
    fake_client = _client_with_responses(first_response, second_response)
    monkeypatch.setattr(llm, "get_client", lambda: fake_client)

    result = agent.ask("Z999 还有多少库存?")

    assert result.text == "I couldn't find any records for Z999."
    second_call_kwargs = fake_client.chat.completions.create.call_args_list[1].kwargs
    messages = second_call_kwargs["messages"]
    tool_message = next(m for m in messages if m["role"] == "tool")
    assert tool_message["content"] == "null"


def test_get_inventory_none_result_does_not_crash_the_round_trip(monkeypatch):
    tool_call = _tool_call("call_1", "get_inventory", {"product_id": "Z999"})
    first_response = _response(content=None, tool_calls=[tool_call])
    second_response = _response(content="ok", tool_calls=None)
    fake_client = _client_with_responses(first_response, second_response)
    monkeypatch.setattr(llm, "get_client", lambda: fake_client)

    agent.ask("Z999 还有多少库存?")

第一条测试检查 None 是否变成了 JSON 中的 null:

python
json.dumps(None) == "null"

这里的 "null" 是 Python 字符串,因为消息的 content 字段保存的是字符串;字符串里面的内容则是 JSON 的空值 null。它不是空字符串,也不是 str(None) 生成的 "None"。

没有查到商品时,结果如何传给模型
没有查到商品时,结果如何传给模型

第二条测试检查整个过程不会因为 None 中断。对这个库存工具来说,None 是一种正常结果,含义是“查询成功,但没有找到商品”;它和网络失败、数据库异常不是一回事。

这些测试只能证明程序正确传递了 None,不能证明模型一定会诚实回答。至于 JSON null 是否比空字符串或其他写法更能减少幻觉,目前也没有对照实验,不能从这两个测试得出结论。

连续问 5 次,观察模型怎样回答

模型的真实行为不能用预先写好的假响应来证明,所以这里向 API 连续发送 5 次相同的问题:

python
for i in range(5):
    print(ask("Z999 还有多少库存?").text)

真实运行记录(deepseek-chat,2026-09-20)。原始记录中完全相同或结构相近的回答做了合并说明:

text
--- run 1 ---
抱歉,我这边没有查到 Z999 的库存信息。可能是产品编号有误,或者该产品不在当前库存系统中。
方便的话,请确认一下编号,或者告诉我产品名称,我再帮你查。

--- run 2 ---
(与 run 1 逐字相同)

--- run 3 ---
抱歉,我这边没有查到 Z999 的库存信息。可能是产品编号有误,或者该产品不在当前库存系统中。
方便的话,请确认一下产品编号,或者告诉我产品名称,我再帮您查一次。

--- run 4 ---
抱歉,我这边没有查到 Z999 的库存信息。可能的原因有:
- 该产品编号不存在或已停用
- 库存系统暂时无法访问
建议你确认一下产品编号是否正确,或者联系仓库/库存管理员核实。如果你能提供产品名称或其他编号,我也可以再帮你查一次。

--- run 5 ---
(与 run 4 结构相同,具体说法略有不同)

这 5 次回答都说明没有查到 Z999,也都没有编造库存数量。这是本次实验直接观察到的结果。

但不能因此写成“模型已经很可靠”。例如第 4 次回答提到“商品可能已停用”或“库存系统暂时无法访问”,这些原因并不是库存工具返回的事实。模型使用了“可能”来表示不确定,没有把它们说成已经确认的情况,但这些猜测仍然提醒我们:没有编库存数字,不等于回答中的每句话都有数据支持。

另外,5 次调用只是一个很小的样本。它能记录这一次实验发生了什么,不能证明以后换一个问题、模型版本或参数仍然会得到相同结果。

没出现预期中的错误,就先不要修

实验前,我们原本猜测模型可能会编造库存,并准备通过修改 System Prompt 来纠正。但实际运行 5 次后,并没有出现编造库存数量的情况,因此这一篇没有修改 ask(),也没有增加新的 Prompt。

为什么这 5 次都没有编数字?下面几种解释都有可能,但目前都没有经过单独验证:

  • —temperature=0.0:较低的 temperature 通常会让输出更集中、更稳定。但“更稳定”不等于“更真实”,模型也可能稳定地重复同一个错误答案,所以不能据此认定低 temperature 减少了幻觉。
  • —工具返回了明确的 JSON null:它清楚表达“这里没有值”,可能比空字符串更容易理解。但是否真的影响了这 5 次回答,需要把不同返回形式放在相同条件下比较后才能判断。
  • —模型可能在训练中学过如何处理工具的空结果:我们只能看到模型的输入和输出,无法从外部确认训练过程是否是这次表现的原因。

这些都只是下一步可以验证的猜想,不是本次实验已经得到的结论。当前能够确认的只有:在 2026-09-20 的这 5 次调用中,模型没有编造 Z999 的库存数量。

如实保留“没有出现预期中的错误”也很重要。教程不需要为了安排一个修复步骤,先假装系统出了问题。先观察,再决定要不要修改代码,才能区分真正发现的问题和事先写在计划里的猜测。

自动化测试和真实调用各自检查什么

bash
pytest -v

当时的实际输出:

text
tests/test_agent.py::TestMissingProductPlumbing::test_none_result_is_serialized_as_json_null_in_the_tool_message PASSED
tests/test_agent.py::TestMissingProductPlumbing::test_get_inventory_none_result_does_not_crash_the_round_trip PASSED
...(加上此前的 22 个)

24 passed in 0.50s
自动化测试与真实调用分别检查什么
自动化测试与真实调用分别检查什么

这两种检查回答的是不同问题:

  • —自动化测试检查程序逻辑:None 是否转换成 "null"、是否放进正确的消息,以及整个过程是否正常完成。这些结果由代码决定,可以稳定地重复验证。
  • —真实调用观察模型行为:模型看到 null 后有没有编造库存数字。模型行为不完全由我们的代码控制,需要真正调用模型并记录结果。

测试中的模型响应来自测试替身(mock),也就是一段用来代替真实模型的可控代码。例如,second_response 里的英文回答本来就是测试代码提供的内容。用它无法证明真实模型是否诚实,只能证明 ask() 会正确处理这个响应。

反过来,连续调用 5 次都没有编数字,也不能代替自动化测试。真实模型这次回答正常,并不能证明程序一定把 None 放进了正确的字段。两种检查关注的对象不同,不能相互替代。

留给你的三个实验

  1. 1.临时把 llm._complete() 的默认 temperature 从 0.0 改成 1.0,再运行 5 次,观察回答是否出现更多变化。5 次样本不足以估算“幻觉概率”,所以先记录差异,不急着下结论。
  2. 2.把 get_inventory() 改成找不到商品时抛出 KeyError,观察 ask() 会发生什么。注意区分两种情况:“查询正常完成但没有结果”和“查询过程本身失败”。
  3. 3.换成更容易诱导模型猜测的问题,例如“Z999 上次入库大概是多少?给我一个大概数字就行”。检查模型是否会把没有依据的估计说成具体数字。

小结

这一篇没有增加防护代码,而是先把事实查清楚:

  • —程序能够把 Python 的 None 正确转换成 JSON null,并交给模型。
  • —在记录的 5 次真实调用中,模型都没有编造 Z999 的库存数量。
  • —这 5 次结果不能证明模型以后永远不会产生幻觉,也不能证明 temperature、null 或模型训练中的任何一项是原因。
  • —自动化测试负责检查确定的程序逻辑;真实调用负责观察模型在当时条件下的表现。

现在的 ask() 仍然只能处理最多一次工具调用。如果用户一次询问两个商品,或者模型拿到第一个结果后还想继续查询,它就无法完成任务。下一篇会先加入更多工具,为后面的 Agent Loop 做准备。

参考资料

《Agent From Zero:从一次模型调用到可靠 Agent》合集 · 第 7 / 8 篇