数据截至 (上游 commit a53b13c7022b)
03 · 结构化输出:和不听话的本地模型打交道
这一章讲一个本地 LLM 特有的难题:云端大模型能稳定按格式吐 JSON,但本地跑的小模型(尤其带推理的 R1 类)经常吐出半截 JSON、混着
<think>思考、或干脆不调工具。项目用「两条路 + 层层兜底」把这件事做稳。
3.1 问题:为什么这里特别难
generate_query 和 reflect_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_provider 和 use_tool_calling 组合出四种实例:
| provider | 模式 | 怎么建 |
|---|---|---|
| ollama | 工具调用 | ChatOllama(...)(普通模式,不加 format) |
| ollama | JSON | ChatOllama(..., format="json") — Ollama 原生 JSON 约束 |
| lmstudio | 工具调用 | ChatLMStudio(...) |
| lmstudio | JSON | ChatLMStudio(..., 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):
- JSON 模式时,给请求塞
response_format={"type": "json_object"}(lmstudio.py:64-66)。 - 拿到原始文本后,手动截出第一个
{到最后一个}之间的子串,验证能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.py | generate_search_query_with_structured_output |
| 四种 LLM 实例装配 | src/ollama_deep_researcher/graph.py | get_llm |
| LMStudio JSON 清洗 | src/ollama_deep_researcher/lmstudio.py | ChatLMStudio._generate |
| 剥离推理标记 | src/ollama_deep_researcher/utils.py | strip_thinking_tokens |
| 模式开关 | src/ollama_deep_researcher/configuration.py | use_tool_calling / strip_thinking_tokens |