Agent 抓回来的是整页 HTML、塞不进上下文,问题通常不在模型,而在工具调用链里「抓取」那一段的返回值形态。可行的做法是保留现有工具名和调用方式,只把这一段的返回从整页 HTML 换成一个包装函数输出的结构化文本块;模型侧看到的东西从「一大坨标记」变成「若干条带字段的记录」,其余链路不动。
适用场景:已有工具调用链、抓取步骤是外呼检索或网页抓取、上下文经常被整页 HTML 挤满。操作动作:先标出查询构造、抓取、拼上下文三段的分界,只替换抓取段的返回值;包装函数的入参定为查询语句、结果条数、字段要求,出参固定为 text / items / meta 三键。验证方式:同一个问题各跑一次 HTML 输入和结构化输入,记录输入长度与回答覆盖的字段。边界:字段缺失严重的问题仍可能需要回退到原文片段,不要一次性把 HTML 通道删掉。
在工具调用链里定位网页抓取那一步
先在代码里把三段分界用注释或日志标出来,避免一上手就改全链路。三段大致是:
- 查询构造段:把用户问题变成检索语句、关键词、过滤条件。这一段决定「搜什么」,本次不动。
- 抓取段:拿到语句后去外呼检索或抓网页,返回的是原始 HTML 或整页正文。这一段决定「拿回来什么」,本次只替换这里。
- 拼上下文段:把抓取结果塞进 prompt、加上前后说明、控制截断。这一段按现有方式不动,只要求它接收一个文本块而不是整页 HTML。
判断依据很简单:如果日志里能在一次工具调用中看到「检索参数」和「模型收到的上下文」两行,中间那段就是抓取段。替换时保持工具名和参数签名不变,让 Agent 侧无需改 prompt 和调用逻辑,这是把改造点压到最小的关键。
定义结构化结果的入参出参契约
契约的作用是让包装函数和 Agent 侧解耦:Agent 只知道要一个文本块,包装函数负责把结构化结果渲染成文本块,中间字段怎么变不影响上层。
入参建议固定四项,其余留默认值:
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。每次记录四项:
| 问题编号 | 输入方式 | 输入字符数 | 回答覆盖的字段数 | 是否需二次追问 |
|---|---|---|---|---|
| Q1 | A:整页 HTML | 填写实测 | 填写实测 | 填写实测 |
| Q1 | B:结构化文本块 | 填写实测 | 填写实测 | 填写实测 |
字符数直接用 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 是给日志和上层判断用的;两者缺一,失败就会被静默吞掉。