跳到主要内容

03 · 结构化输出:和不听话的本地模型打交道

这一章讲一个本地 LLM 特有的难题:云端大模型能稳定按格式吐 JSON,但本地跑的小模型(尤其带推理的 R1 类)经常吐出半截 JSON、混着 <think> 思考、或干脆不调工具。项目用「两条路 + 层层兜底」把这件事做稳。

3.1 问题:为什么这里特别难

generate_queryreflect_on_summary 需要从模型拿到结构化字段(查询字符串、rationale/缺口)。但据 README 明说,DeepSeek R1 的 7B/1.5B 这类小模型「难以产出要求的 JSON」(README.md:151);而新的 gpt-oss 模型在 Ollama 里干脆不支持 JSON 模式,只能走工具调用(README.md:14)。

所以项目给了两条并行的路,由配置项 use_tool_calling 切换(configuration.py:57-61,默认 False):

路线什么时候用怎么拿结果
JSON 模式默认;模型支持 format="json"LLM 直接吐 JSON 字符串,自己 json.loads
工具调用gpt-oss 等不支持 JSON 模式的模型绑定工具类,读 result.tool_calls[0]["args"]

3.2 一个函数收口两条路

两个节点都不各写一遍,而是共用助手 generate_search_query_with_structured_output(graph.py:44-95)。它内部按 use_tool_calling 分叉:

# graph.py:65-95 —— 真实源码(节选,去掉部分注释)
if configurable.use_tool_calling:
llm = get_llm(configurable).bind_tools([tool_class])
result = llm.invoke(messages)
if not result.tool_calls: # 模型压根没调工具
return {"search_query": fallback_query}
try:
tool_data = result.tool_calls[0]["args"]
return {"search_query": tool_data.get(tool_query_field)}
except (IndexError, KeyError): # 调了但结构不对
return {"search_query": fallback_query}
else:
llm = get_llm(configurable)
result = llm.invoke(messages)
content = result.content
try:
parsed_json = json.loads(content)
search_query = parsed_json.get(json_query_field)
if not search_query: # JSON 合法但没这个字段
return {"search_query": fallback_query}
return {"search_query": search_query}
except (json.JSONDecodeError, KeyError): # 根本不是合法 JSON
if configurable.strip_thinking_tokens:
content = strip_thinking_tokens(content)
return {"search_query": fallback_query}

读这段的重点是「每条失败路径都有出口」:

  • 工具调用路:没调工具 → 兜底;调了但缺字段/越界 → 兜底。
  • JSON 路:解析失败 → 兜底;解析成功但缺字段 → 兜底。

兜底值就是上一章说的 Tell me more about {research_topic}(graph.py:186/381)。代价是:一旦兜底,这一轮就退化成一次泛泛的搜索,而不是精准查询——但流程绝不会崩。这是「宁可搜得糙,不可整个卡死」的务实取舍。

3.3 底层:两个 provider × 两种模式

真正建 LLM 的是 get_llm(graph.py:97-135)。它按 llm_provideruse_tool_calling 组合出四种实例:

provider模式怎么建
ollama工具调用ChatOllama(...)(普通模式,不加 format)
ollamaJSONChatOllama(..., format="json") — Ollama 原生 JSON 约束
lmstudio工具调用ChatLMStudio(...)
lmstudioJSONChatLMStudio(..., format="json")

为什么工具调用时不能format="json"?因为工具调用本身要模型输出 tool-call 结构,再套一层 JSON 约束会打架。代码注释也点了这层意思(graph.py:100)。

3.4 LMStudio 的额外一层清洗

Ollama 有原生 format="json",能从底层约束输出。LMStudio 走的是 OpenAI 兼容 API,项目为它单独写了 ChatLMStudio(lmstudio.py:19),在 _generate 里做两件事(lmstudio.py:55-98):

  1. JSON 模式时,给请求塞 response_format={"type": "json_object"}(lmstudio.py:64-66)。
  2. 拿到原始文本后,手动截出第一个 { 到最后一个 } 之间的子串,验证能 json.loads 通过后,用它替换原文本(lmstudio.py:79-90)。
# lmstudio.py:79-90 —— 真实源码(节选)
json_start = raw_text.find("{")
json_end = raw_text.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
json_text = raw_text[json_start:json_end]
json.loads(json_text) # 只为校验
result.generations[0][0].text = json_text # 用干净 JSON 替换

这是在对付「模型在 JSON 前后夹带闲话」的常见毛病——粗暴但有效地抠出中间那坨 JSON。

3.5 剥离 <think> 推理标记

带推理的模型(如 R1)会输出 <think>...</think> 段,这些不该进最终报告。strip_thinking_tokens(utils.py:36-52)用一个 while 循环反复找 <think></think>、把中间连标签一起切掉:

# utils.py:48-52 —— 真实源码
while "<think>" in text and "</think>" in text:
start = text.find("<think>")
end = text.find("</think>") + len("</think>")
text = text[:start] + text[end:]
return text

用在两处:摘要生成后清洗最终摘要(graph.py:325-326);以及 JSON 解析失败的兜底分支里(graph.py:93-94,不过那里剥完并没有重试解析,只是清理后仍返回兜底——属于「尽量还原,但不强求」)。是否剥离由 strip_thinking_tokens 配置控制,默认开(configuration.py:52-56)。

巧妙之处一句话

把「不确定的模型行为」全收进一个函数、每条失败路都通向同一个兜底——这让上层的图逻辑可以假装「结构化输出永远成功」,复杂度被封在一处。这是和本地小模型协作时非常值得借鉴的模式。

代码地图

主题文件符号
结构化输出收口 + 兜底src/ollama_deep_researcher/graph.pygenerate_search_query_with_structured_output
四种 LLM 实例装配src/ollama_deep_researcher/graph.pyget_llm
LMStudio JSON 清洗src/ollama_deep_researcher/lmstudio.pyChatLMStudio._generate
剥离推理标记src/ollama_deep_researcher/utils.pystrip_thinking_tokens
模式开关src/ollama_deep_researcher/configuration.pyuse_tool_calling / strip_thinking_tokens