NeoHorse-1 开源后怎么先跑通一个任务 / 从单轮问答到带工具调用

文章导读
想在 NeoHorse-1 开源后先跑通一个任务,比较稳的顺序是:先写死一条单轮问答链路,确认输出能被你的代码解析;再把工具说明交给模型;最后才让模型自己决定调不调工具。一上来就写多步 Agent 循环,出错时你分不清是提示词、输出解析、工具实现还是循环控制在出问题,排查成本会高出一截。下面五步按顺序做,每一步都有对应的验证方式。
📋 目录
  1. Ⅰ 用单轮问答确认模型输出格式
  2. Ⅱ 把工具说明写成模型能读的清单
  3. Ⅲ 在脚本里拦截工具调用并执行
  4. Ⅳ 观察多步任务在哪一步开始漂移
  5. Ⅴ 给任务加超时和最大轮次
A A

想在 NeoHorse-1 开源后先跑通一个任务,比较稳的顺序是:先写死一条单轮问答链路,确认输出能被你的代码解析;再把工具说明交给模型;最后才让模型自己决定调不调工具。一上来就写多步 Agent 循环,出错时你分不清是提示词、输出解析、工具实现还是循环控制在出问题,排查成本会高出一截。下面五步按顺序做,每一步都有对应的验证方式。

判断方向:先用固定输入的单轮请求确认输出格式,再逐步加工具描述、调用拦截、日志和轮次上限。适用场景是在本地或自建服务上跑通第一个任务;操作动作是按小节顺序推进,每步都靠原始输出和日志验证;边界是模型对工具参数的理解会随提示词和版本变化,换任务时需要重新跑一遍这几步,不能直接沿用上一次的配置。

用单轮问答确认模型输出格式

单轮问答的目的不是测模型有多聪明,而是确认它到底返回纯文本还是结构化字段。如果它默认输出一段自然语言,你后面写 json.loads 只会不断抛异常,还容易误判成模型能力问题。用一个 endpoint 和模型名可替换的通用骨架先跑一次:

payload = {
    'model': 'neohorse-1',
    'messages': [
        {'role': 'system', 'content': '只输出一行 JSON,不要解释。'},
        {'role': 'user', 'content': '用一句话说明这句话的情绪倾向。'}
    ],
    'temperature': 0
}
raw = call_model(endpoint, payload)   # 换成你自己的 HTTP 调用
print(repr(raw))                      # 先看原文,别急着 json.loads

打印时建议用 repr 而不是 print,否则换行、行首行尾空格、被包在 ``` 里的情况你很难看出来。解析失败时要把原始输出留存到一个本地文件,比如 raw_output.log,建议保留前 1000 个字符左右,不要截断到几十字符——太短就只能看到“格式不对”,看不出是格式问题还是内容跑偏。同时记下响应里你关心的元信息,例如 finish_reason、token 计数,用来区分“模型没说完”和“模型说完了但不是 JSON”。这一步能稳定解析之后,再进入工具环节。

把工具说明写成模型能读的清单

工具说明不是给人看的文档,而是给模型看的约束。写清单时至少覆盖四件事:工具名、用途、参数类型、什么情况下不该调用。参数要区分必填和可选,空值处理要写清楚——缺参数时让模型说明缺哪个,而不是自己编一个值填进去。下面是可以直接替换的文本模板:

可用工具清单:
- 工具名:get_weather
  用途:查询指定城市某一天的天气
  参数:city(string,必填);date(string,YYYY-MM-DD,可选,默认今天)
  空值处理:缺少必填参数时不要猜测,直接回复缺少哪个参数
  不要调用的情况:用户只是闲聊,或问的是概念解释而非具体数据

这份清单通常放在系统提示词里,或者在每轮请求前拼接到上下文中。工具数量少的时候全量列出就行;工具多了要先筛选,只把当前任务可能用到的那几个塞进去,模型面对几十个工具时选错的概率会明显上升。这里只写描述,不要写具体业务工具的实现代码,实现放到下一节的本地函数里。

在脚本里拦截工具调用并执行

这一步是把模型的输出映射到你本地的函数上,关键是别让模型的输出直接决定执行什么。先做白名单校验,再用 try/except 把调用包起来,执行前后各打一条日志:

NeoHorse-1 开源后怎么先跑通一个任务 / 从单轮问答到带工具调用
def dispatch(model_text, tools):
    try:
        obj = json.loads(extract_json(model_text))
    except Exception as e:
        log.warning('parse_failed err=%s raw=%s', e, model_text[:500])
        return None
    name = obj.get('tool')
    args = obj.get('args') or {}
    if name not in tools:
        log.warning('unknown_tool name=%s', name)
        return None
    log.info('call_begin tool=%s args=%s', name, args)
    try:
        result = tools[name](**args)
    except TypeError as e:
        log.warning('bad_args tool=%s err=%s', name, e)
        return None
    except Exception as e:
        log.error('tool_error tool=%s err=%s', name, e)
        return {'error': str(e)}
    log.info('call_end tool=%s result_len=%d', name, len(str(result)))
    return result

extract_json 是你自己的提取逻辑,负责从可能带有前后缀的文本里抠出 JSON 片段,抠不出来就按解析失败处理。工具报错不要直接抛到主循环,而是转成一个结构化的错误结果回传给模型,让它有机会换参数或换工具。返回值统一做长度统计,这一项在后面查漂移时很有用。不要假设某个工具一定返回某个字段,先把返回值原样打印一轮,确认结构再写下游逻辑。

观察多步任务在哪一步开始漂移

多步任务跑歪,通常出现在三个位置:工具结果太长把上下文挤掉、模型漏了必填参数、循环次数上限设得不合理。要定位到具体哪一步,日志里至少留这些字段:轮次、工具名、参数、耗时、错误、返回长度。一行日志可以长这样:

ts=... round=1 tool=get_weather args={'city': 'x'} elapsed_ms=N result_len=N err=
ts=... round=2 tool=get_weather args={'date': 'x'} elapsed_ms=N result_len=N err=missing_city

按轮次回看的顺序建议是:先按 round 排序,找到最后一次参数完整的调用;看它之后每一轮的 args 是否开始缺字段;再看 result_len 有没有突然变大,比如某个工具把整页 JSON 都塞回给模型;最后看同一个工具是不是被用同一组参数反复调用。如果是工具结果太长,就在返回给模型之前做裁剪,只保留必要字段;如果是参数缺失,回到上一节的工具清单,把必填约束写得更直白;如果是循环停不下来,下一节的限制会兜住。

给任务加超时和最大轮次

Agent 卡在循环里消耗资源是很常见的情况,跑通任务之后第一件事就是加上限。三个值通常够用:最大轮次、单步超时、总超时。放在配置文件里,方便按任务调整:

agent:
  max_rounds: 6
  step_timeout_s: 20
  total_timeout_s: 90
  on_max_rounds: partial
  on_timeout: partial

触发之后的返回策略建议是“返回已完成部分 + 明确说明未完成的原因”,而不是抛一个裸异常或者返回空结果。比如已经查到天气但还没生成总结,就把天气结果和一句“达到最大轮次,总结未生成”一起返回,下游调用方还能用。单步超时用来掐住某个慢工具,总超时用来兜住整体,两者不要只设一个。具体数值需要结合你的工具响应速度和任务复杂度确认,先用保守值跑几轮,观察正常任务实际用了多少轮次再调。