☰
Azure OpenAI Agent调用AI Search只出JSON不生成答案?根因与修复
2026/9/26 3:18:40 网站建设 项目流程

最近帮朋友调一个 Azure 上的 Agent 项目,遇到一个特别典型的问题:我们在 Azure OpenAI 里创建了一个 Agent,挂上了 Azure AI Search 作为检索工具,期望它回答用户问题时能够先搜资料、再组织语言给出答案。结果实测下来,Agent 确实“干活”了,但只干了一半——它返回了一堆 JSON 引用,像是从搜索引擎原样抄过来的结果片段,而不是一句完整的人话。问题一出,很多人第一反应是“模型坏了”,但其实这是 Agent 工作流里一个非常经典的工具调用中断问题。

这篇文章就把这个“只出 JSON 不出答案”的问题彻底拆开讲一遍,包括根因、定位方法、修复方案,以及我在实际调试中踩过的坑和建议。

1. 问题复现:Agent 返回的不是答案,是检索日志

1.1 故障现象

先看一个最小复现场景。我在 Azure OpenAI Studio 的 Assistants API 里创建了一个 Agent,工具列表中挂载了 Azure AI Search 检索工具。用户提问是:“我们上季度的销售总额是多少?”这个问题的预期答案应该是根据检索到的文档片段,整理出一段话:“上季度销售总额为 2350 万元,同比增长 12%……”。

但实际返回的是类似下面这样的内容:

[ { "@search.score": 0.032156, "content": "2024年四季度销售总额为2350万元,同比增加12%。", "source": "sales_summary_2024.md", "title": "Q4 Sales Summary" }, { "@search.score": 0.028941, "content": "本季度华北区贡献了主要增长,主要原因是新客群拓展。", "source": "sales_analysis.md" } ]

如果是在 API 层面调试,你会发现delta或者最终消息里的content就是这段 JSON 字符串,甚至有时候会把@search.score这种原始字段也带出来。用户当然不满意,因为问的是“多少”,得到的是一个仿佛没解析完的数据结构。

1.2 这个 JSON 引用到底是什么

这段 JSON 实际上就是 Azure AI Search 返回的原始检索结果。Azure AI Search 的 SDK 或者 REST API 查询时,返回的search_results就是一组文档对象,包含命中分数(@search.score)、检索命中的字段(content、source等)以及默认的元数据。

换句话说,Agent 确实调用了检索工具,并且拿到了搜索结果。问题在于,这整个流程并没有走完。在标准的“检索 + 生成”架构下,JSON 引用只是中间产物,它应该作为大模型的上下文输入,用来生成最终答案。现在它却被当成了最终输出。

这里的“Agent Retrieval”指的就是 Agent 执行检索工具的这个动作,而“生成答案”是后续的第二步动作。如果第二步没有执行,你看到的就是引用而不是答案。

2. 根因分析:为什么引用会替代答案

2.1 Agent 工具调用的完整生命周期

要理解这个现象,先得把 Agent 的调用逻辑理顺。一个标准 Agent 的执行过程不是“用户问一句,模型回一句”这么简单,它通常包含如下几个阶段:

  1. 用户提交问题。
  2. LLM 根据指令判断,如果要得到答案,需要检索外部数据。
  3. LLM 输出一个结构化指令,告诉执行层“调用搜索工具”,并带上搜索参数。
  4. 执行层(比如 OpenAI 的 Assistants API 或自定义的 function calling 循环)真正去调 Azure AI Search,拿到 JSON 格式的检索结果。
  5. 这个 JSON 结果会被回传给 LLM 作为新消息。
  6. LLM 依据这个 JSON 上下文,生成面向用户的自然语言答案。
  7. 最终答案是第 6 步的结果,而不是第 4 步的原始 JSON。

关键就在第 4 步和第 6 步之间。AI Search 工具只是把文档片段拉回来,它不会自动“说话”。如果你看到输出是 JSON,那基本可以判断为流程在第 4 步之后断了。

2.2 最常见的中断原因

结合我实际排查过的项目,中断通常由下面三种原因导致。

第一种是“拿了工具输出就直接返回”。也就是在自定义 function calling 的代码里,你写了一个循环:模型说“我要调用 search”,然后你执行搜索,然后把search_result直接作为最终应答返回给前端。这个逻辑看起来“完成了工具调用”,但实际上跳过了“让模型消化工具结果”这一步。你等于拿了一个中间产物冒充最终产物。

第二种是“用错了 API 层级”。很多人以为把 Azure AI Search SDK 接进来就是 Agent,实际上只是调用了 SearchClient 的search()方法拿到 JSON。这一步只完成了检索,后续的生成部分完全没接入。这个问题的命名很符合:你用了一个检索 API,却期望它有 Agent 的生成能力。

第三种是“Agent 指令不明确”。如果你用的是 Assistants API 的检索工具,模型本可以将工具结果作为上下文然后生成回答。但如果系统提示词写得不够明确,比如没有要求“基于检索结果给出简洁自然语言答案”,模型可能就会把工具返回结果原样呈现出来,特别是某些模型的指令遵循能力不够强的时候,这种错误尤其容易发生。

3. 定位问题:确保你找到卡在哪一步

3.1 查看消息记录中的角色与工具痕迹

第一步,把 Agent 会话里的消息列表完整打印出来,看每个消息的role和内容。如果使用 Azure OpenAI Assistants API,有一个接口能拉取 Thread 里的 message 列表。我习惯写一个调试脚本:

from openai import AzureOpenAI client = AzureOpenAI( azure_endpoint="https://your-resource.openai.azure.com/", api_key="your-key", api_version="2024-05-01-preview" ) run_thread_id = "thread_xxx" messages = client.beta.threads.messages.list(thread_id=run_thread_id) for msg in messages.data: print(f"role: {msg.role}") for content in msg.content: text = content.text.value if content.type == "text" else content.type print(text) print("---")

在正常流程里,你会看到类似这样的消息顺序:

  • user:上季度的销售总额是多少?
  • assistant:调用search_sales_data工具,参数为{"query": "上季度销售总额"}(这里在消息内容里会显示为 function call 或者工具调用标记)。
  • tool:返回了那段 JSON 引用。
  • assistant:最终自然语言答案。

如果你看到最后一条assistant消息的内容就是那段 JSON,或者消息序列直接中断在tool层,就说明模型根本没有在工具结果之后继续生成。

3.2 检查你用的是 Agent 还是原生搜索

这一步很多人会忽略。你需要确认当前调用链路的实际结构。如果在代码里直接用了azure-search-documents的SearchClient.search()方法,那你走的根本不是 Agent 链路,而是普通检索。该方法的返回值就是SearchResult的迭代器,序列化后自然是 JSON。

from azure.search.documents import SearchClient from azure.search.documents.models import SearchOptions results = search_client.search( search_text="上季度销售总额", top=3 ) for result in results: print(result["@search.score"]) print(result["content"])

这就是纯检索,没有任何模型生成环节。此时你得到 JSON 是完全正常的。想象一下,如果你把这段代码结果直接返回给用户,你看到的当然只有 JSON。

所以,排查第一步就是问自己:我用的是 Agent 服务,还是仅仅用了搜索客户端?如果是后者,那问题不是“Agent 不生成答案”,而是“你根本没有 Agent”。

4. 解决方案:让 Agent 把 JSON 变成人话

4.1 方案一:手动完成 tool call 后的二次生成

如果你在做自定义 Agent,比较简单粗暴的方式是手动模拟完整的 function calling 循环。流程就是:

  1. 调用模型,传入用户消息和工具定义。
  2. 模型返回工具调用指令。
  3. 执行搜索,得到 JSON 结果。
  4. 将 JSON 结果附加为一条tool角色消息,再调用一次模型。
  5. 拿到最终自然语言输出。

下面是一个精简但完整可运行的思路,用的是 OpenAI Python SDK 里的 chat completions(Azure 同样适用):

from openai import AzureOpenAI import json client = AzureOpenAI( azure_endpoint="https://your-resource.openai.azure.com/", api_key="your-key", api_version="2024-06-01" ) def search_documents(query): # 这里使用你的 Azure AI Search 逻辑,返回 json 字符串 return json.dumps(search_json_list(query), ensure_ascii=False) messages = [ {"role": "user", "content": "上季度的销售总额是多少?"} ] tools = [ { "type": "function", "function": { "name": "search_documents", "description": "搜索企业内部文档或数据报告", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "用户的搜索意图"} }, "required": ["query"] } } } ] # 第一次调用:让模型决定是否调用搜索 response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, tool_choice="auto" ) # 检查是否是工具调用 if response.choices[0].message.tool_calls: tool_call = response.choices[0].message.tool_calls[0] args = json.loads(tool_call.function.arguments) query = args["query"] search_result = search_documents(query) # 将工具结果追加到消息 messages.append(response.choices[0].message) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": search_result }) # 第二次调用:让模型基于检索结果生成最终答案 final_response = client.chat.completions.create( model="gpt-4o", messages=messages, ) print(final_response.choices[0].message.content) else: # 模型没有调用工具,直接生成 print(response.choices[0].message.content)

关键在于第二次create。如果缺失,你拿到的就是第一次响应的tool_calls,而很多人的朋友就是在这一步随手把search_result打印给前端了。

4.2 方案二:用高层 RAG 编排链一步到位

如果不打算手动写循环,又不想依赖 Assistants 的隐式行为,直接用 LangChain 的 RetrievalQA 链反而是更稳的路径。LangChain 有现成的AzureSearch向量存储类,也有AzureChatOpenAI模型封装,可以这些组件组装成一条链。

from langchain.vectorstores.azure_search import AzureSearch from langchain.chat_models import AzureChatOpenAI from langchain.chains import RetrievalQA from langchain.embeddings import AzureOpenAIEmbeddings # 初始化检索器 embedder = AzureOpenAIEmbeddings( azure_deployment="text-embedding-3-small", openai_api_version="2023-05-15" ) vector_store = AzureSearch( azure_search_endpoint="https://your-search.search.windows.net", azure_search_key="your-search-key", index_name="your-index", embedding_function=embedder.embed_query, ) retriever = vector_store.as_retriever(search_type="similarity", search_kwargs={"k": 3}) # 初始化 LLM llm = AzureChatOpenAI( azure_deployment="your-gpt4o-deployment", openai_api_version="2024-02-01", api_key="your-openai-key", temperature=0.2 ) qa_chain = RetrievalQA.from_chain_type( llm=llm, chain_type="stuff", retriever=retriever, return_source_documents=True ) result = qa_chain("上季度的销售总额是多少?") print(result["result"])

这段代码的好处是 LangChain 内部自动完成了“检索 -> 拼接上下文 -> 大模型生成”的过程,最终result["result"]就是自然语言答案,而source_documents里的 JSON 引用则被保留在后台,适合做来源标注。

4.3 方案三:在 Agent 指令里锁死生成要求

如果你用 Assistant API 自带的检索工具,除了确保没有改错调用方式之外,还应该用“指令补丁”来避免模型偷懒输出 JSON。比如在instructions字段里写清楚:

当你使用检索工具获得引用内容后,需要基于引用内容,以自然语言回答用户问题。 禁止直接输出原始检索结果、JSON 数组、Markdown 表格或字段堆砌。 回答应当简洁,引用来源可放在括号后面。

指令看似简单,但效果很直接。很多模型之所以返回 JSON,就是因为你没有告诉它“不可以直接引用”。模型以为用户想看的就是“工具输出的原始格式”。一旦在指令中明确“必须生成答案”,模型就会老老实实走生成流程。

另一个小细节是,部分模型在处理工具结果时会把 JSON 直接放入回答区域,可能是因为工具结果的 token 没有被很好地标记为“上下文”。用 Assistants API 时,工具结果天然是tool角色,理论上不会混入答案。但如果你调用的是 chat completions 并手动塞入了带role: "tool"的消息,注意确保 SDK 版本支持该角色,不支持的话会引发解析错误。

5. 检索参数与后处理:让你的引用更“值得吃”

5.1 调优 Azure AI Search 的返回参数

不要让所有 JSON 引用都变成模型输入。模型是有上下文窗口限制的,而且冗余内容会稀释关键信息。通常我会在搜索调用时重点设置三个参数:

  • top(或$top):只取前 3-5 条结果。取太多反而会把低分的噪声也丢给模型。
  • select:只返回模型中真正会用到的字段,比如content、title。不要返回@odata.etag、空 array 等无用信息。
  • query_type:如果索引配置了语义排序,建议设置query_type="simple"转成语义查询,或者使用 semantic rerank 功能来提升命中质量。语义排序的@search.score可能会变得更好解释,模型从高分结果里提取内容也更容易。

一个实用配置示例:

from azure.search.documents.models import SearchOptions search_options = SearchOptions( top=3, select=["content", "title"], query_type="semantic", semantic_configuration_name="my-semantic-config", ) results = search_client.search( search_text=query, search_options=search_options )

这样得到的 JSON 会小很多,每一条基本就是title+ 简化后的content,模型处理起来压力小得多。

5.2 生成的上下文压缩技巧

即便你做了select,有些文档的content字段也可能很长。这时候还要做一层上下文裁剪。我习惯写一个简单的封顶函数,截断到约 800 到 1200 字符,并根据搜索分数排序,把高分文档放在最前面。这个动作能明显提升答案的召回准确率,因为大模型会更注意前面的上下文。

def summarize_results(results, max_chars=1200): snippets = [] total_len = 0 for rank, r in enumerate(results): text = r.get("content", "").replace("\n", " ").strip() if total_len + len(text) > max_chars: text = text[: max_chars - total_len] snippets.append(f"<doc id={r.get('title', rank)}> {text} </doc>") total_len += len(text) if total_len >= max_chars: break return "\n".join(snippets)

然后用这个压缩后的字符串作为工具返回内容,模型既不会因为上下文过长而截断,也不会被无关字段干扰。

6. 常见问题排查速查表

下面是我整理的一些故障表现、可能原因和对应的解决方法,方便大家收藏备用。

故障表现可能原因解决思路
Agent 返回数组形式的 JSON,且带@search.score直接调用了 Azure Search SDK,未接入 LLM 生成用 LangChain / Prompt Flow 编排检索+生成,或手动实现 function calling 二次生成
Agent 执行了工具调用,但最终消息是工具结果工具调用后没有将tool消息再次交给模型补上第二次 LLM 调用,或使用 Assistants API 的隐式循环
Assistant API 返回 JSON,但内容没有自然语言instructions没写清楚,模型以为用户需要原始引用在指令中明确“禁止直接输出检索结果,只输出基于引用的答案”
偶尔返回 JSON,偶尔返回正常答案工具返回值恰好被模型当成了直接回复内容降低温度;对工具结果做摘要后再传给模型;增加强制生成提示
搜索出的文档不相关,导致答案乱答索引字段/content 映射不正确,或搜索语义匹配差检查索引的searchable_fields、语义配置,调整top和分数过滤阈值
返回的 JSON 有parse error或结构损坏tool角色的消息在 API 中不受支持,或内容没被 double string 包裹确认 SDK 版本支持tool角色;用json.dumps对结果做一次安全序列化

最后再分享一个我自己很受益的小技巧:不要急着让 Agent 直接端到端地输出答案。可以先让模型判定“是否需要检索”,检索之后,把检索摘要单独作为一轮消息再送一次。这种“手动两段式”看似多一次调用,但稳定性和可观测性都会好很多。在多个真实项目里,这种方案几乎没有再遇到“JSON 当答案”的问题。如果你也在 Azure 上折腾 Agent + AI Search,不妨先按这个思路自查一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询