把 IQuest-Q1 接进已有的 Agent 框架,真正卡住的地方通常不是模型本身,而是三层格式没对齐:提示模板里的角色标记跟模型侧训练用的模板不一致、自有的工具清单描述字段名对不上、模型吐出的工具调用片段不是严格 JSON。建议的处理顺序是先把模板对齐,再转工具描述,然后发一次最简请求把原始返回原样存盘,最后照着存下来的样本写解析层。反过来先写解析、再猜模型输出格式,遇到格式漂移时整条链路容易直接断掉。
适用场景:自己已有 Agent 框架,只把 IQuest-Q1 当作推理与工具调用的一环。动作:逐项对照仓库模板文件里的角色标记与工具段落位置,把工具清单转成固定字段的描述,发一次最简请求保存原始返回,解析层按截取标记段、尝试解析、失败回填重试的顺序写。验证:看日志里第二轮请求的 messages 是否带上工具返回结果。边界:模型返回若不遵守标记约定,只能退化为纯文本解析,模板与解析层都要留兜底分支。
在仓库模板文件里确认角色标记与工具段落写法
这一步的目标不是改模型,而是让自己拼出来的提示结构和模型训练时使用的模板一致。先找到仓库里的模板文件(常见命名如 template、chat_template、prompt_config 之类),把它和你的 prompt 构造函数放在一起逐项对照。
grep -rn -e "tool_call" -e "assistant" -e "system" -e "eos" ./templates ./configs | head -50
| 对照项 | 模板文件里要确认的内容 | 自己框架需要改的字段 |
|---|---|---|
| 角色标记 | system / user / assistant 各自的起止写法 | 拼 messages 时使用的 role 名与分隔符 |
| 分隔符 | 每轮之间用什么符号切分,结尾是否带 EOS | 拼接函数里的 join 逻辑 |
| 工具段落位置 | 工具定义是放在 system 内部,还是独占一段 | prompt 构造函数里工具段的插入点 |
| 工具结果回填 | 结果用哪个角色名回填、字段名叫什么 | 回填消息的 role 与 content 键名 |
| 调用输出标记 | 模型输出工具调用时用什么包裹 | 解析层用的正则或分割标记 |
对照完把不一致的字段列成一张改动清单再动手,比边跑边改容易定位问题。如果模板文件本身不可改,就要在自己的 prompt 构造函数里做适配,而不是在解析层兜。
把自有 Agent 的工具清单转成模型可读的描述
模型只能看到你写进请求里的文字,它不知道你代码里有哪些函数。工具描述要回答两件事:有哪些工具、每个工具的参数叫什么。下面是一个通用骨架,字段名和嵌套层级请按你所用框架的实际约定替换,不要直接照抄。
{
"tools": [
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "查询订单当前状态。只接受订单号,不接受其他条件。",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号,纯数字字符串"}
},
"required": ["order_id"]
}
}
}
]
}
必填项通常是 name、description、parameters.type、parameters.properties、parameters.required。有的框架把这几个字段平铺,不套 function 一层,这时候要按框架要求改,而不是把两种写法混用。description 里写清楚边界(比如“不接受其他条件”“金额单位为元”),能减少模型编造参数的情况;参数层级不要超过三层,嵌套过深时模型容易漏填必填项。
发一次最简工具调用请求并保存原始返回
先只放一个工具、一句明确会触发该工具的问题,跑通一次即可。关键动作是把未经处理的返回原样写文件,不要在请求代码里顺手做字段提取,否则后面比对时看不到模型的真实输出。
import json, time, pathlib
resp = client.chat(
model="iquest-q1",
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
stamp = time.strftime("%Y%m%d-%H%M%S")
pathlib.Path("raw").mkdir(exist_ok=True)
pathlib.Path(f"raw/{stamp}.json").write_text(
json.dumps(resp, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
print(resp)
</path>
把 raw 目录下的文件用编辑器打开,逐字看三件事:工具名是否原样出现、参数是否包在标记段里、标记段之外有没有多余解释文字。这三点决定了解析层要写成什么样。若客户端对象不能直接序列化,用 default=str 兜一下即可,目的只是留档。
写解析层并处理非标准 JSON 的返回
解析层要假设模型输出会漂移:标记段外有寒暄、JSON 带尾随逗号、参数被单引号包住、一次返回多个调用。按“截取标记段 → 尝试解析 → 失败则回填提示重试”的顺序写,任何一支失败都不要让主流程抛异常退出。
import json, re
CALL_RE = re.compile(r"<tool_call>(.*?)</tool_call>", re.S)
def parse_tool_calls(text):
calls, errors = [], []
for block in CALL_RE.findall(text or ""):
block = block.strip()
try:
calls.append(json.loads(block))
continue
except json.JSONDecodeError as e:
fixed = re.sub(r",\s*([}\]])`, r"\1", block)
fixed = fixed.replace("'", '"')
try:
calls.append(json.loads(fixed))
except json.JSONDecodeError as e2:
errors.append({"block": block, "error": str(e2)})
if not calls and not errors and text:
errors.append({"block": text, "error": "no_tool_call_marker"})
return calls, errors
回填重试时,把出错片段和错误原因一起放进下一条消息,并明确要求只输出 JSON,不要代码块围栏和解释文字:
def build_retry_message(errors):
detail = "\n".join(
f"- 片段:{e['block']}\n 错误:{e['error']}" for e in errors
)
return (
"上一次返回的工具调用无法解析,请只输出一个合法的 JSON 对象,"
"不要 Markdown 代码块围栏,不要解释文字。\n" + detail
)
重试次数建议设成有限值,比如两次;仍失败就返回一个明确的错误结果给上层,而不是继续向模型追问。工具名不在注册表里、必填参数缺失这两类校验,也放在这一层做,错误信息同样按上面的格式回填。
把单次调用接进多轮循环,检查第二轮上下文
单次跑通后,用最小循环把结果回填回去,确认模型能接着往下走。下面的骨架里,MAX_TURNS 要设上限,避免模型反复要工具导致死循环。
messages = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": question}]
for turn in range(MAX_TURNS):
resp = client.chat(model="iquest-q1", messages=messages, tools=TOOLS)
text = extract_text(resp)
calls, errors = parse_tool_calls(text)
if errors:
messages.append({"role": "user", "content": build_retry_message(errors)})
continue
if not calls:
break
messages.append({"role": "assistant", "content": text})
for c in calls:
result = dispatch(c) # 本地执行,失败也要返回结构化错误
messages.append({
"role": "tool",
"content": json.dumps(result, ensure_ascii=False),
})
log_messages(turn + 1, messages)
检查点从日志里看三项:第二轮请求的 messages 里,assistant 那条是否保留了模型原始的工具调用文本;工具结果条数是否等于本轮解析出的调用数;回填使用的 role 名是否和模板文件里确认的一致。任何一项对不上,问题通常在拼接层而不是模型侧。另外把 messages 的总长度打出来,必要时对历史轮次做截断,避免上下文被早期工具结果占满。