Agent 搜到的网页还是原始 HTML / AnySearch 的结构化结果怎么接

文章导读
Agent 抓回来的是整页 HTML、塞不进上下文,问题通常不在模型,而在工具调用链里「抓取」那一段的返回值形态。可行的做法是保留现有工具名和调用方式,只把这一段的返回从整页 HTML 换成一个包装函数输出的结构化文本块;模型侧看到的东西从「一大坨标记」变成「若干条带字段的记录」,其余链路不动。
📋 目录
  1. 壹 在工具调用链里定位网页抓取那一步
  2. 贰 定义结构化结果的入参出参契约
  3. 叁 写工具包装函数的骨架
  4. 肆 对比两种输入的实际占用
  5. 伍 把失败分支显式写进返回值
A A

Agent 抓回来的是整页 HTML、塞不进上下文,问题通常不在模型,而在工具调用链里「抓取」那一段的返回值形态。可行的做法是保留现有工具名和调用方式,只把这一段的返回从整页 HTML 换成一个包装函数输出的结构化文本块;模型侧看到的东西从「一大坨标记」变成「若干条带字段的记录」,其余链路不动。

适用场景:已有工具调用链、抓取步骤是外呼检索或网页抓取、上下文经常被整页 HTML 挤满。操作动作:先标出查询构造、抓取、拼上下文三段的分界,只替换抓取段的返回值;包装函数的入参定为查询语句、结果条数、字段要求,出参固定为 text / items / meta 三键。验证方式:同一个问题各跑一次 HTML 输入和结构化输入,记录输入长度与回答覆盖的字段。边界:字段缺失严重的问题仍可能需要回退到原文片段,不要一次性把 HTML 通道删掉。

在工具调用链里定位网页抓取那一步

先在代码里把三段分界用注释或日志标出来,避免一上手就改全链路。三段大致是:

  • 查询构造段:把用户问题变成检索语句、关键词、过滤条件。这一段决定「搜什么」,本次不动。
  • 抓取段:拿到语句后去外呼检索或抓网页,返回的是原始 HTML 或整页正文。这一段决定「拿回来什么」,本次只替换这里。
  • 拼上下文段:把抓取结果塞进 prompt、加上前后说明、控制截断。这一段按现有方式不动,只要求它接收一个文本块而不是整页 HTML。

判断依据很简单:如果日志里能在一次工具调用中看到「检索参数」和「模型收到的上下文」两行,中间那段就是抓取段。替换时保持工具名和参数签名不变,让 Agent 侧无需改 prompt 和调用逻辑,这是把改造点压到最小的关键。

定义结构化结果的入参出参契约

契约的作用是让包装函数和 Agent 侧解耦:Agent 只知道要一个文本块,包装函数负责把结构化结果渲染成文本块,中间字段怎么变不影响上层。

入参建议固定四项,其余留默认值:

Agent 搜到的网页还是原始 HTML / AnySearch 的结构化结果怎么接
  • query:查询语句,纯文本,由查询构造段传入。
  • top_k:期望返回条数上限,用于控制拼装长度。
  • fields:字段要求列表,例如标题、链接、摘要、发布时间;缺省时给一组最小字段。
  • timeout_ms 与 max_chars:超时阈值和文本块字符上限,属于工程兜底,不必暴露给模型。

出参固定为三键,避免上层再去解析字符串:

{
  'text':  '[1] 标题…\nURL: …\n摘要: …',   // 给模型读的文本块
  'items': [{'title': '…', 'url': '…', 'snippet': '…'}],  // 给程序读的原始条目
  'meta':  {'query': '…', 'count': 5, 'truncated': false, 'reason': None}
}

必须携带的元信息至少包括:原始查询语句、实际返回条数、是否被 max_chars 截断、失败原因码。前两项用于回放和定位,截断标记防止模型把「只看到三条」当成「全网只有三条」,原因码让失败分支可判断。

写工具包装函数的骨架

下面这段是通用骨架,PROVIDER.search 是占位符,换成你实际的结构化检索入口即可;函数名也按现有命名习惯改。

def web_search_structured(query, top_k=5, fields=None,
                          timeout_ms=8000, max_chars=6000):
    req = {
        'query': query,
        'size': top_k,
        'fields': fields or ['title', 'url', 'snippet'],
        'timeout_ms': timeout_ms,
    }
    try:
        resp = PROVIDER.search(req)          # 替换项:结构化检索调用
    except TimeoutError:
        return fail_result('timeout', query, {'timeout_ms': timeout_ms})
    except Exception as exc:
        return fail_result('upstream_error', query, {'detail': str(exc)})

    items = normalize_items(resp, req['fields'])   # 统一字段名,缺失置空串
    if not items:
        return fail_result('empty', query, {'top_k': top_k})

    text, truncated = render_block(items, max_chars)
    return {
        'text': text,
        'items': items,
        'meta': {'query': query, 'count': len(items),
                 'truncated': truncated, 'reason': None},
    }


def render_block(items, max_chars):
    lines = []
    for i, it in enumerate(items, 1):
        lines.append('[%d] %s\nURL: %s\n摘要: %s' %
                     (i, it.get('title', ''), it.get('url', ''), it.get('snippet', '')))
    text = '\n\n'.join(lines)
    return text[:max_chars], len(text) > max_chars


def fail_result(reason, query, extra):
    msg = {
        'empty': '未检索到与「%s」匹配的结构化结果。' % query,
        'timeout': '检索「%s」超时,未获得任何结果。' % query,
        'upstream_error': '检索「%s」失败,未获得任何结果。' % query,
    }[reason]
    meta = {'query': query, 'count': 0, 'truncated': False, 'reason': reason}
    meta.update(extra)
    return {'text': msg + '请勿据此推断事实,应说明信息不足或更换关键词重试。',
            'items': [], 'meta': meta}

拼装格式刻意保持扁平:编号 + 标题 + 链接 + 摘要,不用嵌套 JSON 直接喂模型,因为模型对逐行文本的读取更稳定,出问题时也便于在日志里肉眼核对每一条。

对比两种输入的实际占用

替换是否值得,要自己记录,不要凭感觉。挑三个形态不同的问题(单一事实型、需要比较型、需要近期信息型),每个问题走两条路径:A 路径按现有方式把抓取到的整页 HTML 直接拼进上下文;B 路径只拼包装函数返回的 text。每次记录四项:

Agent 搜到的网页还是原始 HTML / AnySearch 的结构化结果怎么接
问题编号输入方式输入字符数回答覆盖的字段数是否需二次追问
Q1A:整页 HTML填写实测填写实测填写实测
Q1B:结构化文本块填写实测填写实测填写实测

字符数直接用 len(prompt) 或现有 tokenizer 统计即可,不需要额外工具。「回答覆盖的字段数」按问题本身的要点清单核对,比如链接是否可点、时间是否给出、数值是否有出处;「是否需二次追问」用来判断覆盖度有没有下降。

判读时注意两点:B 路径的输入长度通常小于 A,但如果答案藏在页面正文深处而不在摘要字段里,B 的覆盖度可能反而下降,这时应回到 fields 补字段,或在 render_block 里保留一段原文片段。另外不要只跑一次就下判断,同一问题换两次检索返回往往结论不同,记录要留存输入原文,方便回看是长度问题还是检索质量问题。

把失败分支显式写进返回值

无结果时最危险的不是报错,而是模型拿着空上下文自行补全。三种情况都要有固定文案和后续动作,并且统一走 fail_result,让 meta.reason 可读:

  • 空结果(reason=empty):返回「未检索到与「查询」匹配的结构化结果」。后续动作:让模型换关键词或缩小问题范围,最多重试一次,仍为空则明确告诉用户没有找到,而不是给一个看起来像答案的推测。
  • 超时(reason=timeout):返回「检索超时,未获得任何结果」,同时带上 timeout_ms。后续动作:不要自动拉长超时反复重试,先降 top_k 或简化查询;如果同一 query 多次超时,记录到日志再判断是不是外呼侧的问题。
  • 字段不全(reason=partial_fields):结果存在但缺链接或缺时间。后续动作:保留已有条目并逐条标注「该条缺少某某字段」,让模型只使用已有字段作答,禁止补齐缺失值。这类情况建议不要把 items 清空,否则等于把可用信息一起丢掉。

这三种返回都要在 text 里显式写出「未获得结果 / 缺少字段」,而不是只靠 meta 传递。因为模型读的是 text,meta 是给日志和上层判断用的;两者缺一,失败就会被静默吞掉。