IQuest-Q1 先验证单轮对话、再验证工具调用返回

文章导读
IQuest-Q1 要不要继续投入,可以先看两件互不依赖的事:单轮推理能不能返回可用文本,带工具的请求返回能不能被程序解析。前者用最小输入跑一次并保存原始输出,后者在同一套调用骨架上加一个工具再跑一次,看工具名和参数落在哪个字段里。两次都只依赖你手头能看到的原始返回和日志,不需要先接入完整 Agent 逻辑。
📋 目录
  1. A 用最小输入跑一次单轮推理并保存原始输出
  2. B 在输出里定位工具调用的标记段落
  3. C 改一次工具参数再跑,比较两次返回结构
  4. D 写解析函数把返回转成程序可用结构
  5. E 把解析失败的样本单独存档
A A

IQuest-Q1 要不要继续投入,可以先看两件互不依赖的事:单轮推理能不能返回可用文本,带工具的请求返回能不能被程序解析。前者用最小输入跑一次并保存原始输出,后者在同一套调用骨架上加一个工具再跑一次,看工具名和参数落在哪个字段里。两次都只依赖你手头能看到的原始返回和日志,不需要先接入完整 Agent 逻辑。

先确认单轮推理能拿到非空、可读的文本输出,再拿一次带工具调用的请求看工具名和参数放在哪一层。两步都只依赖你本地能看到的原始返回和日志,不依赖任何效果指标。若单轮无输出或工具段落无法稳定定位,先不要继续接入 Agent 解析,把提示模板和工具描述改到能复现再谈下一步。

用最小输入跑一次单轮推理并保存原始输出

目的不是测模型能力,是拿到一份最干净的基线输出,后面出问题时可以对照。请求里只放一条 user 消息,不挂工具,也不加系统提示词。用通用 HTTP 接入骨架就可以,字段名按你环境实际替换。

import requests

BASE_URL = 'https://your-endpoint'
API_KEY = '...'
payload = {
    'model': 'iquest-q1',
    'messages': [{'role': 'user', 'content': '只回复:pong'}],
    'stream': False,
}
r = requests.post(BASE_URL + '/v1/chat/completions',
                  headers={'Authorization': 'Bearer ' + API_KEY},
                  json=payload)
open('run_single_raw.json', 'wb').write(r.content)

关键在落盘方式:用 wb 写 r.content,保留原始字节。不要写成 r.json() 再 dump,也不要在终端里先用 jq 美化后才存,那样会丢掉原始格式和可能的流式分片。如果接口默认返回 SSE 流,先把整段原始字节写进文件,再另存一份可读副本。验证时打开 run_single_raw.json,确认非空且能看到 choices 或你接入层的等价字段。风险边界:stream 默认值、请求路径、鉴权头名称可能不同,需要结合环境确认。

在输出里定位工具调用的标记段落

第二次请求保持调用方式不变,只在 payload 里加一个工具定义,然后把原始返回存成 run_tool_raw.json。工具调用通常落在 assistant message 的某个数组字段里,但不同实现在包裹层和字段名上有差异。先逐字摘出这一段,不要先改写它。

{
  'choices': [
    {'message': {
      'role': 'assistant',
      'content': None,
      'tool_calls': [
        {'id': 'call_abc',
         'type': 'function',
         'function': {'name': 'get_weather',
                      'arguments': 'city=杭州'}}
      ]
    }}
  ]
}

标注字段名:choices[0].message.tool_calls 是工具调用数组;tool_calls[].id 是一次调用的标识;tool_calls[].function.name 是工具名;tool_calls[].function.arguments 是参数。会随工具不同的位置包括:工具名、参数内容、参数类型,以及有没有额外的包裹层。有的返回可能不出现 tool_calls,而是把调用写在 content 里用 XML 标签或自定义标记包起来。建议先用 jq '.choices[0].message.tool_calls' run_tool_raw.json 定位,找不到再全文搜索工具名。风险边界:不要假设所有工具都走同一个字段。

IQuest-Q1 先验证单轮对话、再验证工具调用返回

改一次工具参数再跑,比较两次返回结构

复制第一次的 payload,只改工具参数,比如第一次传 city=杭州,第二次传 city=杭州并加 units=metric,或者换成另一个工具。把原始返回另存为 run_tool_raw_2.json。然后对照两次返回的结构,而不是对照文本内容。

观察项第一次返回第二次返回是否漂移
tool_calls 路径choices[0].message.tool_callschoices[0].message.tool_calls路径不变
arguments 类型字符串,如 city=杭州字符串,如 city=杭州;units=metric类型未变
字段顺序id、type、functiontype、id、function顺序变了,按字段名取值不受影响
额外包裹层无无无

如果第二次出现 data 或 result 这类外层包裹,或者 arguments 从字符串变成对象,解析函数需要做归一化。如果只是字段顺序变,不影响按字段名取值。验证方式:对两次原始文件跑同一条定位命令,确认路径是否一致。风险边界:参数数量变化可能让部分实现从单工具调用变成多工具调用,需要单独看一次数组长度。

写解析函数把返回转成程序可用结构

定位到字段后,写一个解析函数把文本返回转成程序可用的字典,后续 Agent 逻辑不必直接处理原始文本。函数骨架如下,错误信息用固定前缀,方便在日志里搜索。

import json

def parse_tool_call(raw_text):
    try:
        payload = json.loads(raw_text)
    except json.JSONDecodeError as e:
        raise ValueError('RAW_NOT_JSON: %s at pos %s' % (e.msg, e.pos)) from e

    try:
        msg = payload['choices'][0]['message']
    except (KeyError, IndexError, TypeError) as e:
        raise ValueError('NO_MESSAGE_PATH: top_keys=%s' % list(payload.keys())) from e

    calls = msg.get('tool_calls')
    if not calls:
        return {'kind': 'text', 'text': msg.get('content')}

    out = []
    for c in calls:
        fn = c.get('function', {})
        args_raw = fn.get('arguments')
        if isinstance(args_raw, str):
            try:
                args = json.loads(args_raw)
            except json.JSONDecodeError as e:
                raise ValueError('BAD_ARGUMENTS: name=%s raw=%r' % (fn.get('name'), args_raw)) from e
        else:
            args = args_raw
        out.append({'id': c.get('id'), 'name': fn.get('name'), 'args': args})
    return {'kind': 'tool_calls', 'calls': out}

输入输出示例:把 run_single_raw.json 的原始内容喂进去,函数返回 {'kind': 'text', 'text': 'pong'}。把 run_tool_raw.json 的原始内容喂进去,函数返回 {'kind': 'tool_calls', 'calls': [{'id': 'call_abc', 'name': 'get_weather', 'args': {'city': '杭州'}}]}。解析失败时抛出固定前缀错误,如 RAW_NOT_JSON、NO_MESSAGE_PATH、BAD_ARGUMENTS。验证方式:两次原始文件分别过一遍解析函数,单轮走 text 分支,工具返回走 tool_calls 分支;如果工具返回报 RAW_NOT_JSON,先确认没有把流式 SSE 整段塞进来。

IQuest-Q1 先验证单轮对话、再验证工具调用返回

把解析失败的样本单独存档

解析失败不要只截一张图或只留美化后的片段,否则很难回头调提示模板或工具描述。建一个 failures/ 目录,每条失败存一个 JSON 文件,字段至少包括:

  • raw_output:原始返回,保留原始字节或原样文本
  • prompt:完整 messages,不要只留用户那一句话
  • tool_schema:这次请求携带的工具 JSON Schema 或函数描述
  • model:模型名或接入层标识
  • error:解析函数抛出的错误文本,带上固定前缀
  • timestamp:本地记录时间,便于按顺序排列

示例文件可以写成下面这样,字段名按你项目习惯调整,但 raw_output、prompt、tool_schema 三项建议不要省。

{
  'raw_output': '...',
  'prompt': [{'role': 'user', 'content': '查杭州天气'}],
  'tool_schema': [{'name': 'get_weather',
                   'parameters': {'type': 'object',
                                  'properties': {'city': {'type': 'string'}},
                                  'required': ['city']}}],
  'model': 'iquest-q1',
  'error': 'BAD_ARGUMENTS: name=get_weather raw=...'
}

复现时用保存下来的请求体重跑。示意命令如下,rerun_tool.py 读 payload 文件发请求并把原始返回写到标准输出或文件,parse_check.py 调用上面的解析函数并打印结果。

python rerun_tool.py payload_get_weather.json > rerun_tool_raw.json
python parse_check.py rerun_tool_raw.json

重跑前确认 payload 里的 messages 和 tool_schema 没有被改写。如果错误前缀依旧出现,说明该失败样本可复现,可以拿它去调整提示模板或工具描述;如果重跑后解析通过,先保留原始失败样本和这次成功的原始返回,再决定是否回滚改动。