1. 为什么Agent非要联网不可:模型知识固化的局限先从一个尴尬的场景说起。我本地部署了一个开源大模型,问它“今天上海天气怎么样”,它一本正经地告诉我今天晴转多云,然后我在阳台被雨淋了个透。又试了一次:“最近发布的某款新手机,起售价是多少?”它开始编参数,甚至把上一代的配置搬了出来。问题出在哪儿?模型的知识是有截止日期的。绝大多数模型的训练数据停在某个时间点,之后发生的事情对它来说完全是盲区。这件事放在单轮问答里还能忍,一旦牵涉到Agent场景就完全不行了。Agent的核心价值是“替用户执行任务”,而任务往往依赖实时信息:查天气、盯股价、比价、查航班、抢票、找最新的技术文档。如果Agent只能靠记忆作答,它就不是Agent,是个复读机。所以“联网”这两个字,是Agent从玩具走向工具的分水岭。联网之后又冒出两个连锁问题:第一,Agent是一次性问答还是多轮对话?用户不可能一句话把需求说完,必然会追问、修正、反悔。多轮对话意味着Agent要记住上下文,而上下文里又混入了联网抓回来的网页内容,这些东西都占token。第二,token怎么算、怎么控、怎么省?这不是省钱的问题,是能不能跑得下去的问题。上下文窗口有限,联网内容又多,聊几句就爆窗口的Agent我见过太多。所以这篇我把整个链路拆开讲:联网检索怎么接、多轮状态怎么维持、token怎么精打细算,最后给一个能直接跑的Demo。适合两类人:一是刚接触Agent开发、想把“调用API”升级成“能干活”的开发者;二是已经在做Agent、但被多轮对话和上下文管理折磨到头疼的从业者。2. 联网Agent的整体架构:工具调用是骨架,搜索管线是血肉2.1 先理解“工具调用”在Agent里的位置Agent要联网,不是直接把网页内容塞给模型——第一步是让模型“知道”它可以去网上查。这件事在实践里主要有两种做法。一种是“提示词硬控法”:你在系统提示词里写清楚,“当用户问实时信息时,你需要调用search工具查询”,然后把搜索接口的调用方法告诉模型,让模型在回复内容里输出一个特定格式的指令,比如[[SEARCH:今天上海天气]]。代码层拿到这个指令就去调搜索接口,把结果拼接回上下文再重新请求模型。这种方法实现简单、透明可控,适合原型验证,但缺点也很明显:模型容易漏触发或多触发,指令格式稍微错一点就匹配不上。另一种是官方标准的“Function Calling / Tool Calling”机制。模型在推理时会额外输出一个结构化指令(通常是JSON),内容类似“我想调用search工具,参数是{query: '今天上海天气', city: '上海'}”,由代码层解析后执行真实工具,再把工具返回值送回模型。这个机制的好处是参数结构化、可靠度高,主流模型和Agent框架都支持,已经成了行业事实标准。实操上我更推荐后者。不要自己发明协议,除非你想维护一套和全世界都没法兼容的接口。2.2 从“搜到”到“用上”:完整链路拆解联网检索看着简单,其实是一个三段式管线:搜索阶段:调用搜索接口(类比你自己打开浏览器输入关键词)。搜索接口返回的是条目列表,每条包含标题、链接、摘要。注意,这一步返回的信息密度不一定够,光是摘要往往喂不饱模型。抓取阶段:选定前几条结果,抓取网页正文内容。这里有个问题,很多页面反爬或者结构杂乱,直接抓回来一堆导航、广告、弹窗脚本。实践中需要用正文提取工具做内容清洗。消化阶段:把清洗后的正文按token预算截断,注入上下文,让模型作为参考资料进行总结和回答。这三段各有各的坑。搜索阶段的问题是关键词生成质量——模型生成的搜索词可能太口语化或太宽泛;抓取阶段的问题是内容噪声大;消化阶段的问题是token占用高。后面我会给出每个环节的应对策略。2.3 一个能“记住”的Agent需要什么:从无状态到有状态单轮联网问答只需要“拿搜索词—调接口—拼上下文—出结果”四个步骤。但多轮对话让它复杂了至少三倍:用户在第2轮说“还是搜一下A吧”,模型得知道“A”指的是上一轮说的某件事;用户在第3轮说“那B呢?”,模型得把B和前面的话题关联起来;用户在第5轮问“刚才那个价格是多少来着?”,这要求系统至少保留前几轮的问答内容。也就是说,多轮Agent必须有一个“状态容器”,要么自己管理历史消息对象,要么依赖框架的Memory机制。而联网结果要不要进这个容器?我见过两种做法:一种是临时注入——搜索结果只在本轮使用,结束后丢弃;另一种是永久归档——把搜索结果提炼成摘要,塞回历史消息。选择没有绝对对错,关键看你产品的定位:如果用户回头问“你刚才查到的那个数据是什么”,临时注入就答不上来;如果用户只是随口追问现场消息,永久归档又会白白占用大量token。多数产品会折中:保留“搜索摘要”而不保留“原始网页全文”。3. 多轮对话的上下文管理:会话窗口、记忆裁剪与注入策略3.1 三层会话结构:别把所有消息塞进一个篮子我建议把Agent的对话状态拆成三层,别让所有消息混在一起。这套结构在多轮联网场景里非常实用:系统层(System):固定指令、角色设定、工具定义、行为约束。每次请求都会带,但它不应该频繁变动,也不应该膨胀。历史层(History):用户和助手的对话记录,保持原始形式或经过压缩的摘要。当轮上下文(Current Turn):本轮用户输入、联网检索出来的临时结果、系统内部运行日志(比如调用了哪个工具、返回了什么状态)。为什么要分三层?因为三层的生命周期不同:系统层是常量,历史层是累计变量,当轮上下文是临时变量。把临时变量塞进历史层,是我见过最多的内存泄漏——每轮聊完都把搜索结果原样塞进历史,聊到第8轮,历史里躺着6大段网页正文,窗口直接爆掉。3.2 上下文窗口的预算分配公式当你把一条请求发出去,它实际吃掉的是:总输入tokens = 系统提示词 tokens + 历史消息 tokens + 当前输入 tokens + 联网结果 tokens假设你的模型上下文窗口是128K,我的建议是给“当前用户输入+联网结果”留足40-50%的预算,给系统提示词和工具定义留10-15%,剩下的给历史消息。因为本轮任务是当前最需要上下文空间的,历史可以压缩,但本轮的搜索结果往往决定回答质量。这里面有个反直觉的地方:很多人的token爆炸不是模型吃得多,而是历史层攒得太肥。用户信息量其实不大,但Agent每轮回答都很啰嗦,模型为了凑长度生成大段废话,几十轮下来历史全是空气。所以控制历史层不只靠裁剪,还得靠“回答质量规范”——让模型尽量精简作答,省下来的窗口全是红利。3.3 三种历史裁剪方案:截断、摘要、滑动窗口多轮对话聊天记录怎么处理?无非三种思路,各有适用场景:截断:只保留最近N轮,更早的丢掉。最简单粗暴,适合闲聊型Agent。损失是跨多轮的长期信息会丢,比如第2轮聊过的需求,第15轮可能会被提起。滑动窗口:定长窗口,新的进来旧的出去。本质上还是截断,但实现上更优雅,适合固定预算场景。社区里常用deque(maxlen=N)来做,代价同样是不能恢复遥远记忆。摘要压缩:用模型把早期对话提炼成一段摘要,替换原始消息。适合长会话、强记忆场景。代价是多一次模型调用、多一笔token开销,而且摘要会丢失细节。我个人实践中最稳的组合是:平时用“截断+保留最近10轮完整记录”,当历史长度超过阈值时,把更早的部分压缩成摘要。这样既保留最近细节,又不会让远古信息彻底消失。注意压缩摘要本身也占token,一般控制在200-500字以内,否则得不偿失。3.4 非对称裁剪:记住“承诺”和“数字”,忘掉“寒暄”更进一步,我建议做“结构感知”的裁剪——不是机械地按轮次切,而是按信息类型保。多轮对话中的信息价值差异极大:“明天下午三点开会”这种具体约定,丢了会误事;“好的呢”“明白了”这种客套或者确认,丢了毫无影响。我自己会让Agent在每轮回答时顺带输出一个“记忆标记”(属于内部运行数据,不进用户可见输出),标记当前轮里哪些是“需要长期记住”的信息(姓名、数字、日期、偏好),哪些是“会话级”信息。裁剪时优先丢会话级信息,保住承诺和数字。这套机制的效果远超无脑滑窗。4. tokens的计量、成本泄漏点与预算控制4.1 tokenizer不只是“切词”,它决定你花的每一分钱很多初学者不理解token到底怎么算的,我打个比方:tokenizer是模型理解文本的“基本单元”窗口,英文一个单词通常1-2个token,中文一个字通常1个token(不同模型可能略有差异,有的模型中文按字切、有的按词切,还有的会对重复字符做特殊压缩)。你的“预算”,本质就是往这个窗口里塞多少切好的单元。同一个提问,中英文、繁简体、不同表达方式的token数可以差两三倍。我自己有段时间把提示词从英文改成中文,同样的逻辑描述,token直接少掉近一半。所以做Agent的第一个省钱技巧就藏在语言选择里——前提是你的模型对中文支持够好。4.2 四个隐蔽的token泄漏点实操里我发现,token消耗总是比预期高,罪魁祸首通常是以下四个地方,每个我都踩过:一是系统提示词膨胀。一开始写了5条规则,后来加了3个例子,又追加2条边界说明,再塞一个few-shot示例。不知不觉系统提示词从500字涨到3000字。模型每次请求都要重读这一大段,这是固定成本、刚性支出。所以系统提示词要设计成“增量式”:基础版一套,特殊场景追加模块,别把所有规则常驻。二是工具定义过长。Function Calling机制需要把每个工具的名称、描述、参数Schema发给模型,这部分也算token。如果你定义了20个工具,每个描述写200字,光工具定义就是4000字。实战建议只定义会真实用到的工具,描述写到“语义够用就行”,别写论文。三是联网结果的整段塞入。搜索接口返回5条结果,每条摘要400字,再加上抓取的网页正文3000字,一次联网就吃掉5000字。如果你每轮都联网,每轮都塞,成本直接爆炸。办法是只保留前三名的结果,并用摘要替代正文——除非任务明确要求原文精确数据,否则不要让模型看网页全文。四是历史消息重复计费。你每发一次请求,历史消息都要完整重发一遍。50轮对话,第50轮的请求里包含了前49轮的全部文本。这个在API计费体系下是照单全收的,没有“增量计费”这种好事。所以每多一轮,成本往上叠一层。没用的寒暄要主动压缩掉,早压缩早省钱。4.3 预算控制三板斧:上限、阈值、熔断要防止token失控,光靠节省不够,得有硬性机制:设置单轮上限:比如每轮联网结果的token上限为2500,超了就截断;历史层上限为8000,超了就压缩或裁断。系统提示词上限为2000。设置会话总预算:比如一个会话最多消耗5万token(包括输入输出),到达4.5万时提醒用户“快超了”,到5万就拒绝继续。客户对话、付费场景特别需要这个。熔断机制:如果某轮联网返回的内容特别大(比如抓到一个2万字的网页),立刻丢弃而不是截断——因为截断后的乱七八糟内容还不如不读。有一个细节需要注意:输出tokens同样收费,而且模型有时会复读、会反复生成“好的,我来帮你查询”这样的寒暄。会话里设置max_tokens能限制单次输出长度,但完全抑制啰嗦还得靠提示词和温度参数配合。5. 实操:手写一个可运行的“联网多轮对话Agent”Demo5.1 架构选型:没必要上来就上重型框架很多人一上来就引入一堆Agent框架,结果光安装依赖就折腾一天。做原型阶段,我建议最多用一个轻量级工具,或者干脆手写。下面这个Demo依赖极简,可以本机直接跑通:requests:调搜索接口和模型接口json:解析结构化输出urllib.parse:拼接URL模型侧:任意提供Chat Completion接口的模型服务,我用MaaS_URL和MODEL_NAME占位,你替换成自己实际使用的即可核心数据结构就一个conversation_list,用列表存历史消息,每轮对话后追加用户消息和助手消息。这样最小可用,后续维护也不费劲。5.2 代码骨架:检索、上下文组装、多轮循环import requests import json # 占位参数,换成你自己的真实配置 MaaS_URL = "https://your-llm-api.example.com/v1/chat/completions" MODEL_NAME = "your-multimodal-model" # 以你实际使用的模型为准 API_KEY = "your-api-key" SEARCH_API = "https://your-search-api.example.com/search" # 系统提示词:既定义联网工具,也要求精简输出 SYSTEM_PROMPT = """你是联网助手。当用户需要实时信息时,调用search工具。 搜索关键词要提取核心实体,避免冗余。 回答时先给出结论,再给来源。不要输出“好的”“我来看看”这类客套。 """ + "工具:search。调用方式:在回答前输出JSON {\"action\": \"search\", \"query\": \"关键词\"}" conversation_list = [ {"role": "system", "content": SYSTEM_PROMPT}, ] def call_llm(messages): payload = { "model": MODEL_NAME, "messages": messages, "temperature": 0.3, "max_tokens": 512, } headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"} resp = requests.post(MaaS_URL, headers=headers, json=payload, timeout=60) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] def search_web(query): params = {"q": query, "count": 3, "language": "zh"} r = requests.get(SEARCH_API, params=params, timeout=30) results = r.json().get("items", []) res_text = [] for item in results: title = item.get("title", "") snippet = item.get("snippet", "") link = item.get("link", "") res_text.append(f"标题:{title}\n摘要:{snippet}\n来源:{link}") summary = "\n\n".join(res_text) # 预算控制:搜索结果超过2000字就截断 return summary[:2000] def run_agent(user_input): # 加用户消息 conversation_list.append({"role": "user", "content": user_input}) # 第一轮:让模型判断是否需要搜索 reply = call_llm(conversation_list) # 尝试从回复头部解析出search工具调用 action = None try: if reply.startswith("{"): parsed = json.loads(reply) if parsed.get("action") == "search": action = parsed["query"] except Exception: action = None if action: # 执行搜索 search_res = search_web(action) # 把搜索结果作为系统消息插入,然后又跑一轮模型 tool_context = { "role": "system", "content": f"以下是联网搜索结果,回答时优先参考:\n{search_res}", } tmp_messages = conversation_list + [tool_context] final_reply = call_llm(tmp_messages) # 清理掉临时的联网上下文,避免历史膨胀 conversation_list.append({"role": "assistant", "content": final_reply}) return final_reply else: conversation_list.append({"role": "assistant", "content": reply}) return reply # 五轮对话测试 while True: user_input = input("你:") if user_input.lower() in ("quit", "exit"): break output = run_agent(user_input) print("助手:", output)这段代码逻辑很直白:先让模型决定要不要搜索,要搜就调接口,把结果临时塞进上下文再跑一次,最后把临时内容扔掉、只保留正式回复。这里有个巧妙的设计:联网结果是作为“临时system消息”注入的,而不是追加进历史列表,这样就不会污染多轮记忆。5.3 复现时最容易翻车的5个点搜索结果字段名不一样。不同的搜索接口返回结构不同,有的叫items,有的叫results,有的没有link字段。写代码前先打印一段原始返回值看看,别想当然。Action解析失败。模型输出的JSON不总是标准JSON,经常夹杂前后解释。前面示例代码只做简单判断,实际上线建议用正则提取{.*?}块,再交给JSON解析。更稳妥的走Function Calling机制,让框架来解析。模型上下文不兼容。有些模型不支持中文系统提示词里的“不要客套”指令,照样输出“好的,让我帮你查一下”。这种调几次就发现模型性格改不了,要么换更强的模型,要么后端做一层输出清洗。多轮会话里用户指代歧义。第3轮问“它多少钱”,模型如果看不到第2轮的上下文就懵了。这正是为什么历史消息不能丢。但历史消息过长又会爆窗口——所以就有了递归摘要的需求。在上面的Demo里,第10轮之后建议把早期消息替换成一段摘要。联网接口超时。搜索接口偶尔要3-5秒,加上模型推理时间,整个响应可能过10秒。客户端要有耐心提示,后端要有超时重试,否则用户体验极差。5.4 给Demo加一个轻量级“记忆压缩”模块正式做产品时,上面的Demo还不够。我再给一段记忆压缩的扩展:def compress_history(conversation_list, max_rounds=8): # 系统消息保持独立,其余消息按轮打包压缩 sys_msgs = [m for m in conversation_list if m["role"] == "system"] newest_msgs = conversation_list[- max_rounds * 2:] # user+assistant各一条算一轮 old_msgs = conversation_list[1:- max_rounds * 2] if max_rounds > 0 else conversation_list[1:] if len(old_msgs) <= 2: return conversation_list # 历史还短,不压缩 # 让模型把旧历史压成一段摘要 compress_input = { "role": "user", "content": f"把下面对话压缩成200字以内的摘要,保留所有数字、名字、约定:\n{json.dumps(old_msgs, ensure_ascii=False)}", } summary = call_llm(sys_msgs + [compress_input]) compressed_placeholder = {"role": "system", "content": f"[早期对话摘要] {summary}"} return sys_msgs + [compressed_placeholder] + newest_msgs这段代码会在历史轮数超标时触发压缩:旧消息变成摘要,新消息完整保留。注意摘要里“保留所有数字、名字、约定”这个要求很重要,否则模型会不负责任地吞掉关键信息。6. 我踩过的坑:联网内容可信度、延迟和上下文污染6.1 搜索结果是错的,模型照样会“照单全收”最危险的一次:用户问某产品价格,搜索接口返回了一条去年旧闻,模型不管时间戳,直接引用报价。用户火冒三丈。这个锅其实一半在搜索接口——返回结果没有按时效性排序;另一半在Agent——没做“信息时效校验”。我现在的做法是:搜索返回后,先让模型判断一下“这条信息是否可能过期”(通过时间描述、事件描述等信号),标记为不确定的信息必须显式告知用户“时效性未知”。别怕多花一轮token,这种可靠性投入值回票价。6.2 延迟叠加:搜索+模型推理可能突破用户的耐心阈值一个多轮联网Agent的完整耗时是:模型判断是否搜索(1-2秒)+ 搜索接口(2-5秒)+ 网页抓取(1-3秒)+ 模型综合回答(2-4秒)。加起来经常超过10秒。我的改进办法:搜索接口用异步“预查询”:先不急着让模型决定,只要检测到用户消息里包含明确的实体词(如“天气”“价格”“最新”),就预先把搜索结果准备好,等模型说需要时直接给它。抓取阶段只抓前1条正文,其余靠搜索摘要,省掉大部分抓取时间。流式输出(SSE):让用户先看到“正在生成”而不是干等。这不算作弊,这叫体验设计。6.3 上下文污染:联网内容混入多轮记忆前面Demo里我特意强调把联网结果做成“临时注入”。这一点在真实项目里反复验证过:一旦把网页原文直接追加进历史列表,后面任何一轮对话都会受到这份“旧网页”的影响。比如用户第2轮查了天气,第8轮问“推荐一下电影”,模型竟然还在拿“今天有雨”说事。这不是模型笨,是你把仓库里过期的蔬菜一直摆在冰箱前面。所以规则很简单:网页内容是一次性消耗品,用完就扔;只有模型的最终回答可以作为历史保留。6.4 模型“假联网”:它会编造搜索结果这也是个经典坑。你以为模型调用了搜索接口,其实它只是在扮演一个“会搜索的助手”,被问“最近有什么新闻”时直接自己编新闻。隐患在家里:如果模型压根没有走工具调用路径,你压根没法拦截和审核内容。我的补救是给回答加上“来源编号”强制要求:模型必须引用搜索结果条目编号(比如[1]),代码层再校验编号对应的链接真的存在于本次搜索结果里。编造的引用会暴露无遗。这比提示词管用得多。7. 最后的几点实操建议把联网、多轮对话和token管理揉在一起,本质上是在做三件事:让模型接上活水(实时数据)、让模型记住上下文(历史状态)、让模型在预算内干活(token控制)。这三件事没有哪个可以省,也没有哪件是一劳永逸的。根据我自己的经验,给几条直接可以抄作业的建议:别急着上重型框架,先用最小可用版本跑通你的核心场景。框架解决的是通用问题,你的场景可能根本需要不同的信息处理链路。跑通以后,框架才有意义。给每个联网Agent先定义“超时预算”和“token预算”,再开始写代码。这个话题下,预算不是上线前的优化项,而是需求文档的一部分。先定了预算,架构就会自然倾向“精简”,很多失控都会被反向遏制。把搜索质量和内容清洗当成一等公民。很多Agent效果差,不是模型不行,是喂给它的网页内容太脏。花时间调正文提取,远比换一个更大的模型更划算。多轮对话里,把“压缩摘要”当成一个持续动作,而不是事后的补救措施。每次回复之后,哪怕历史不太长,也可以顺手生成一个“记忆要点”存起来,后面真要压缩时直接拼接要点,不需要重新读一遍全部历史。做这类Agent,最深的体会是:模型的能力天花板已经在哪儿摆着了,真正拉开差距的是工程侧的数据管线、上下文管理和预算设计。联网不是给模型加一个搜索按钮那么简单,它是一个完整的实时信息链路。把这套链路跑顺了,你的Agent才真正开始“活”起来,不然永远是玩具。
1. 为什么Agent非要联网不可:模型知识固化的局限先从一个尴尬的场景说起。我本地部署了一个开源大模型,问它“今天上海天气怎么样”,它一本正经地告诉我今天晴转多云,然后我在阳台被雨淋了个透。又试了一次:“最近发布的某款新手机,起售价是多少?”它开始编参数,甚至把上一代的配置搬了出来。问题出在哪儿?模型的知识是有截止日期的。绝大多数模型的训练数据停在某个时间点,之后发生的事情对它来说完全是盲区。这件事放在单轮问答里还能忍,一旦牵涉到Agent场景就完全不行了。Agent的核心价值是“替用户执行任务”,而任务往往依赖实时信息:查天气、盯股价、比价、查航班、抢票、找最新的技术文档。如果Agent只能靠记忆作答,它就不是Agent,是个复读机。所以“联网”这两个字,是Agent从玩具走向工具的分水岭。联网之后又冒出两个连锁问题:第一,Agent是一次性问答还是多轮对话?用户不可能一句话把需求说完,必然会追问、修正、反悔。多轮对话意味着Agent要记住上下文,而上下文里又混入了联网抓回来的网页内容,这些东西都占token。第二,token怎么算、怎么控、怎么省?这不是省钱的问题,是能不能跑得下去的问题。上下文窗口有限,联网内容又多,聊几句就爆窗口的Agent我见过太多。所以这篇我把整个链路拆开讲:联网检索怎么接、多轮状态怎么维持、token怎么精打细算,最后给一个能直接跑的Demo。适合两类人:一是刚接触Agent开发、想把“调用API”升级成“能干活”的开发者;二是已经在做Agent、但被多轮对话和上下文管理折磨到头疼的从业者。2. 联网Agent的整体架构:工具调用是骨架,搜索管线是血肉2.1 先理解“工具调用”在Agent里的位置Agent要联网,不是直接把网页内容塞给模型——第一步是让模型“知道”它可以去网上查。这件事在实践里主要有两种做法。一种是“提示词硬控法”:你在系统提示词里写清楚,“当用户问实时信息时,你需要调用search工具查询”,然后把搜索接口的调用方法告诉模型,让模型在回复内容里输出一个特定格式的指令,比如[[SEARCH:今天上海天气]]。代码层拿到这个指令就去调搜索接口,把结果拼接回上下文再重新请求模型。这种方法实现简单、透明可控,适合原型验证,但缺点也很明显:模型容易漏触发或多触发,指令格式稍微错一点就匹配不上。另一种是官方标准的“Function Calling / Tool Calling”机制。模型在推理时会额外输出一个结构化指令(通常是JSON),内容类似“我想调用search工具,参数是{query: '今天上海天气', city: '上海'}”,由代码层解析后执行真实工具,再把工具返回值送回模型。这个机制的好处是参数结构化、可靠度高,主流模型和Agent框架都支持,已经成了行业事实标准。实操上我更推荐后者。不要自己发明协议,除非你想维护一套和全世界都没法兼容的接口。2.2 从“搜到”到“用上”:完整链路拆解联网检索看着简单,其实是一个三段式管线:搜索阶段:调用搜索接口(类比你自己打开浏览器输入关键词)。搜索接口返回的是条目列表,每条包含标题、链接、摘要。注意,这一步返回的信息密度不一定够,光是摘要往往喂不饱模型。抓取阶段:选定前几条结果,抓取网页正文内容。这里有个问题,很多页面反爬或者结构杂乱,直接抓回来一堆导航、广告、弹窗脚本。实践中需要用正文提取工具做内容清洗。消化阶段:把清洗后的正文按token预算截断,注入上下文,让模型作为参考资料进行总结和回答。这三段各有各的坑。搜索阶段的问题是关键词生成质量——模型生成的搜索词可能太口语化或太宽泛;抓取阶段的问题是内容噪声大;消化阶段的问题是token占用高。后面我会给出每个环节的应对策略。2.3 一个能“记住”的Agent需要什么:从无状态到有状态单轮联网问答只需要“拿搜索词—调接口—拼上下文—出结果”四个步骤。但多轮对话让它复杂了至少三倍:用户在第2轮说“还是搜一下A吧”,模型得知道“A”指的是上一轮说的某件事;用户在第3轮说“那B呢?”,模型得把B和前面的话题关联起来;用户在第5轮问“刚才那个价格是多少来着?”,这要求系统至少保留前几轮的问答内容。也就是说,多轮Agent必须有一个“状态容器”,要么自己管理历史消息对象,要么依赖框架的Memory机制。而联网结果要不要进这个容器?我见过两种做法:一种是临时注入——搜索结果只在本轮使用,结束后丢弃;另一种是永久归档——把搜索结果提炼成摘要,塞回历史消息。选择没有绝对对错,关键看你产品的定位:如果用户回头问“你刚才查到的那个数据是什么”,临时注入就答不上来;如果用户只是随口追问现场消息,永久归档又会白白占用大量token。多数产品会折中:保留“搜索摘要”而不保留“原始网页全文”。3. 多轮对话的上下文管理:会话窗口、记忆裁剪与注入策略3.1 三层会话结构:别把所有消息塞进一个篮子我建议把Agent的对话状态拆成三层,别让所有消息混在一起。这套结构在多轮联网场景里非常实用:系统层(System):固定指令、角色设定、工具定义、行为约束。每次请求都会带,但它不应该频繁变动,也不应该膨胀。历史层(History):用户和助手的对话记录,保持原始形式或经过压缩的摘要。当轮上下文(Current Turn):本轮用户输入、联网检索出来的临时结果、系统内部运行日志(比如调用了哪个工具、返回了什么状态)。为什么要分三层?因为三层的生命周期不同:系统层是常量,历史层是累计变量,当轮上下文是临时变量。把临时变量塞进历史层,是我见过最多的内存泄漏——每轮聊完都把搜索结果原样塞进历史,聊到第8轮,历史里躺着6大段网页正文,窗口直接爆掉。3.2 上下文窗口的预算分配公式当你把一条请求发出去,它实际吃掉的是:总输入tokens = 系统提示词 tokens + 历史消息 tokens + 当前输入 tokens + 联网结果 tokens假设你的模型上下文窗口是128K,我的建议是给“当前用户输入+联网结果”留足40-50%的预算,给系统提示词和工具定义留10-15%,剩下的给历史消息。因为本轮任务是当前最需要上下文空间的,历史可以压缩,但本轮的搜索结果往往决定回答质量。这里面有个反直觉的地方:很多人的token爆炸不是模型吃得多,而是历史层攒得太肥。用户信息量其实不大,但Agent每轮回答都很啰嗦,模型为了凑长度生成大段废话,几十轮下来历史全是空气。所以控制历史层不只靠裁剪,还得靠“回答质量规范”——让模型尽量精简作答,省下来的窗口全是红利。3.3 三种历史裁剪方案:截断、摘要、滑动窗口多轮对话聊天记录怎么处理?无非三种思路,各有适用场景:截断:只保留最近N轮,更早的丢掉。最简单粗暴,适合闲聊型Agent。损失是跨多轮的长期信息会丢,比如第2轮聊过的需求,第15轮可能会被提起。滑动窗口:定长窗口,新的进来旧的出去。本质上还是截断,但实现上更优雅,适合固定预算场景。社区里常用deque(maxlen=N)来做,代价同样是不能恢复遥远记忆。摘要压缩:用模型把早期对话提炼成一段摘要,替换原始消息。适合长会话、强记忆场景。代价是多一次模型调用、多一笔token开销,而且摘要会丢失细节。我个人实践中最稳的组合是:平时用“截断+保留最近10轮完整记录”,当历史长度超过阈值时,把更早的部分压缩成摘要。这样既保留最近细节,又不会让远古信息彻底消失。注意压缩摘要本身也占token,一般控制在200-500字以内,否则得不偿失。3.4 非对称裁剪:记住“承诺”和“数字”,忘掉“寒暄”更进一步,我建议做“结构感知”的裁剪——不是机械地按轮次切,而是按信息类型保。多轮对话中的信息价值差异极大:“明天下午三点开会”这种具体约定,丢了会误事;“好的呢”“明白了”这种客套或者确认,丢了毫无影响。我自己会让Agent在每轮回答时顺带输出一个“记忆标记”(属于内部运行数据,不进用户可见输出),标记当前轮里哪些是“需要长期记住”的信息(姓名、数字、日期、偏好),哪些是“会话级”信息。裁剪时优先丢会话级信息,保住承诺和数字。这套机制的效果远超无脑滑窗。4. tokens的计量、成本泄漏点与预算控制4.1 tokenizer不只是“切词”,它决定你花的每一分钱很多初学者不理解token到底怎么算的,我打个比方:tokenizer是模型理解文本的“基本单元”窗口,英文一个单词通常1-2个token,中文一个字通常1个token(不同模型可能略有差异,有的模型中文按字切、有的按词切,还有的会对重复字符做特殊压缩)。你的“预算”,本质就是往这个窗口里塞多少切好的单元。同一个提问,中英文、繁简体、不同表达方式的token数可以差两三倍。我自己有段时间把提示词从英文改成中文,同样的逻辑描述,token直接少掉近一半。所以做Agent的第一个省钱技巧就藏在语言选择里——前提是你的模型对中文支持够好。4.2 四个隐蔽的token泄漏点实操里我发现,token消耗总是比预期高,罪魁祸首通常是以下四个地方,每个我都踩过:一是系统提示词膨胀。一开始写了5条规则,后来加了3个例子,又追加2条边界说明,再塞一个few-shot示例。不知不觉系统提示词从500字涨到3000字。模型每次请求都要重读这一大段,这是固定成本、刚性支出。所以系统提示词要设计成“增量式”:基础版一套,特殊场景追加模块,别把所有规则常驻。二是工具定义过长。Function Calling机制需要把每个工具的名称、描述、参数Schema发给模型,这部分也算token。如果你定义了20个工具,每个描述写200字,光工具定义就是4000字。实战建议只定义会真实用到的工具,描述写到“语义够用就行”,别写论文。三是联网结果的整段塞入。搜索接口返回5条结果,每条摘要400字,再加上抓取的网页正文3000字,一次联网就吃掉5000字。如果你每轮都联网,每轮都塞,成本直接爆炸。办法是只保留前三名的结果,并用摘要替代正文——除非任务明确要求原文精确数据,否则不要让模型看网页全文。四是历史消息重复计费。你每发一次请求,历史消息都要完整重发一遍。50轮对话,第50轮的请求里包含了前49轮的全部文本。这个在API计费体系下是照单全收的,没有“增量计费”这种好事。所以每多一轮,成本往上叠一层。没用的寒暄要主动压缩掉,早压缩早省钱。4.3 预算控制三板斧:上限、阈值、熔断要防止token失控,光靠节省不够,得有硬性机制:设置单轮上限:比如每轮联网结果的token上限为2500,超了就截断;历史层上限为8000,超了就压缩或裁断。系统提示词上限为2000。设置会话总预算:比如一个会话最多消耗5万token(包括输入输出),到达4.5万时提醒用户“快超了”,到5万就拒绝继续。客户对话、付费场景特别需要这个。熔断机制:如果某轮联网返回的内容特别大(比如抓到一个2万字的网页),立刻丢弃而不是截断——因为截断后的乱七八糟内容还不如不读。有一个细节需要注意:输出tokens同样收费,而且模型有时会复读、会反复生成“好的,我来帮你查询”这样的寒暄。会话里设置max_tokens能限制单次输出长度,但完全抑制啰嗦还得靠提示词和温度参数配合。5. 实操:手写一个可运行的“联网多轮对话Agent”Demo5.1 架构选型:没必要上来就上重型框架很多人一上来就引入一堆Agent框架,结果光安装依赖就折腾一天。做原型阶段,我建议最多用一个轻量级工具,或者干脆手写。下面这个Demo依赖极简,可以本机直接跑通:requests:调搜索接口和模型接口json:解析结构化输出urllib.parse:拼接URL模型侧:任意提供Chat Completion接口的模型服务,我用MaaS_URL和MODEL_NAME占位,你替换成自己实际使用的即可核心数据结构就一个conversation_list,用列表存历史消息,每轮对话后追加用户消息和助手消息。这样最小可用,后续维护也不费劲。5.2 代码骨架:检索、上下文组装、多轮循环import requests import json # 占位参数,换成你自己的真实配置 MaaS_URL = "https://your-llm-api.example.com/v1/chat/completions" MODEL_NAME = "your-multimodal-model" # 以你实际使用的模型为准 API_KEY = "your-api-key" SEARCH_API = "https://your-search-api.example.com/search" # 系统提示词:既定义联网工具,也要求精简输出 SYSTEM_PROMPT = """你是联网助手。当用户需要实时信息时,调用search工具。 搜索关键词要提取核心实体,避免冗余。 回答时先给出结论,再给来源。不要输出“好的”“我来看看”这类客套。 """ + "工具:search。调用方式:在回答前输出JSON {\"action\": \"search\", \"query\": \"关键词\"}" conversation_list = [ {"role": "system", "content": SYSTEM_PROMPT}, ] def call_llm(messages): payload = { "model": MODEL_NAME, "messages": messages, "temperature": 0.3, "max_tokens": 512, } headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"} resp = requests.post(MaaS_URL, headers=headers, json=payload, timeout=60) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] def search_web(query): params = {"q": query, "count": 3, "language": "zh"} r = requests.get(SEARCH_API, params=params, timeout=30) results = r.json().get("items", []) res_text = [] for item in results: title = item.get("title", "") snippet = item.get("snippet", "") link = item.get("link", "") res_text.append(f"标题:{title}\n摘要:{snippet}\n来源:{link}") summary = "\n\n".join(res_text) # 预算控制:搜索结果超过2000字就截断 return summary[:2000] def run_agent(user_input): # 加用户消息 conversation_list.append({"role": "user", "content": user_input}) # 第一轮:让模型判断是否需要搜索 reply = call_llm(conversation_list) # 尝试从回复头部解析出search工具调用 action = None try: if reply.startswith("{"): parsed = json.loads(reply) if parsed.get("action") == "search": action = parsed["query"] except Exception: action = None if action: # 执行搜索 search_res = search_web(action) # 把搜索结果作为系统消息插入,然后又跑一轮模型 tool_context = { "role": "system", "content": f"以下是联网搜索结果,回答时优先参考:\n{search_res}", } tmp_messages = conversation_list + [tool_context] final_reply = call_llm(tmp_messages) # 清理掉临时的联网上下文,避免历史膨胀 conversation_list.append({"role": "assistant", "content": final_reply}) return final_reply else: conversation_list.append({"role": "assistant", "content": reply}) return reply # 五轮对话测试 while True: user_input = input("你:") if user_input.lower() in ("quit", "exit"): break output = run_agent(user_input) print("助手:", output)这段代码逻辑很直白:先让模型决定要不要搜索,要搜就调接口,把结果临时塞进上下文再跑一次,最后把临时内容扔掉、只保留正式回复。这里有个巧妙的设计:联网结果是作为“临时system消息”注入的,而不是追加进历史列表,这样就不会污染多轮记忆。5.3 复现时最容易翻车的5个点搜索结果字段名不一样。不同的搜索接口返回结构不同,有的叫items,有的叫results,有的没有link字段。写代码前先打印一段原始返回值看看,别想当然。Action解析失败。模型输出的JSON不总是标准JSON,经常夹杂前后解释。前面示例代码只做简单判断,实际上线建议用正则提取{.*?}块,再交给JSON解析。更稳妥的走Function Calling机制,让框架来解析。模型上下文不兼容。有些模型不支持中文系统提示词里的“不要客套”指令,照样输出“好的,让我帮你查一下”。这种调几次就发现模型性格改不了,要么换更强的模型,要么后端做一层输出清洗。多轮会话里用户指代歧义。第3轮问“它多少钱”,模型如果看不到第2轮的上下文就懵了。这正是为什么历史消息不能丢。但历史消息过长又会爆窗口——所以就有了递归摘要的需求。在上面的Demo里,第10轮之后建议把早期消息替换成一段摘要。联网接口超时。搜索接口偶尔要3-5秒,加上模型推理时间,整个响应可能过10秒。客户端要有耐心提示,后端要有超时重试,否则用户体验极差。5.4 给Demo加一个轻量级“记忆压缩”模块正式做产品时,上面的Demo还不够。我再给一段记忆压缩的扩展:def compress_history(conversation_list, max_rounds=8): # 系统消息保持独立,其余消息按轮打包压缩 sys_msgs = [m for m in conversation_list if m["role"] == "system"] newest_msgs = conversation_list[- max_rounds * 2:] # user+assistant各一条算一轮 old_msgs = conversation_list[1:- max_rounds * 2] if max_rounds > 0 else conversation_list[1:] if len(old_msgs) <= 2: return conversation_list # 历史还短,不压缩 # 让模型把旧历史压成一段摘要 compress_input = { "role": "user", "content": f"把下面对话压缩成200字以内的摘要,保留所有数字、名字、约定:\n{json.dumps(old_msgs, ensure_ascii=False)}", } summary = call_llm(sys_msgs + [compress_input]) compressed_placeholder = {"role": "system", "content": f"[早期对话摘要] {summary}"} return sys_msgs + [compressed_placeholder] + newest_msgs这段代码会在历史轮数超标时触发压缩:旧消息变成摘要,新消息完整保留。注意摘要里“保留所有数字、名字、约定”这个要求很重要,否则模型会不负责任地吞掉关键信息。6. 我踩过的坑:联网内容可信度、延迟和上下文污染6.1 搜索结果是错的,模型照样会“照单全收”最危险的一次:用户问某产品价格,搜索接口返回了一条去年旧闻,模型不管时间戳,直接引用报价。用户火冒三丈。这个锅其实一半在搜索接口——返回结果没有按时效性排序;另一半在Agent——没做“信息时效校验”。我现在的做法是:搜索返回后,先让模型判断一下“这条信息是否可能过期”(通过时间描述、事件描述等信号),标记为不确定的信息必须显式告知用户“时效性未知”。别怕多花一轮token,这种可靠性投入值回票价。6.2 延迟叠加:搜索+模型推理可能突破用户的耐心阈值一个多轮联网Agent的完整耗时是:模型判断是否搜索(1-2秒)+ 搜索接口(2-5秒)+ 网页抓取(1-3秒)+ 模型综合回答(2-4秒)。加起来经常超过10秒。我的改进办法:搜索接口用异步“预查询”:先不急着让模型决定,只要检测到用户消息里包含明确的实体词(如“天气”“价格”“最新”),就预先把搜索结果准备好,等模型说需要时直接给它。抓取阶段只抓前1条正文,其余靠搜索摘要,省掉大部分抓取时间。流式输出(SSE):让用户先看到“正在生成”而不是干等。这不算作弊,这叫体验设计。6.3 上下文污染:联网内容混入多轮记忆前面Demo里我特意强调把联网结果做成“临时注入”。这一点在真实项目里反复验证过:一旦把网页原文直接追加进历史列表,后面任何一轮对话都会受到这份“旧网页”的影响。比如用户第2轮查了天气,第8轮问“推荐一下电影”,模型竟然还在拿“今天有雨”说事。这不是模型笨,是你把仓库里过期的蔬菜一直摆在冰箱前面。所以规则很简单:网页内容是一次性消耗品,用完就扔;只有模型的最终回答可以作为历史保留。6.4 模型“假联网”:它会编造搜索结果这也是个经典坑。你以为模型调用了搜索接口,其实它只是在扮演一个“会搜索的助手”,被问“最近有什么新闻”时直接自己编新闻。隐患在家里:如果模型压根没有走工具调用路径,你压根没法拦截和审核内容。我的补救是给回答加上“来源编号”强制要求:模型必须引用搜索结果条目编号(比如[1]),代码层再校验编号对应的链接真的存在于本次搜索结果里。编造的引用会暴露无遗。这比提示词管用得多。7. 最后的几点实操建议把联网、多轮对话和token管理揉在一起,本质上是在做三件事:让模型接上活水(实时数据)、让模型记住上下文(历史状态)、让模型在预算内干活(token控制)。这三件事没有哪个可以省,也没有哪件是一劳永逸的。根据我自己的经验,给几条直接可以抄作业的建议:别急着上重型框架,先用最小可用版本跑通你的核心场景。框架解决的是通用问题,你的场景可能根本需要不同的信息处理链路。跑通以后,框架才有意义。给每个联网Agent先定义“超时预算”和“token预算”,再开始写代码。这个话题下,预算不是上线前的优化项,而是需求文档的一部分。先定了预算,架构就会自然倾向“精简”,很多失控都会被反向遏制。把搜索质量和内容清洗当成一等公民。很多Agent效果差,不是模型不行,是喂给它的网页内容太脏。花时间调正文提取,远比换一个更大的模型更划算。多轮对话里,把“压缩摘要”当成一个持续动作,而不是事后的补救措施。每次回复之后,哪怕历史不太长,也可以顺手生成一个“记忆要点”存起来,后面真要压缩时直接拼接要点,不需要重新读一遍全部历史。做这类Agent,最深的体会是:模型的能力天花板已经在哪儿摆着了,真正拉开差距的是工程侧的数据管线、上下文管理和预算设计。联网不是给模型加一个搜索按钮那么简单,它是一个完整的实时信息链路。把这套链路跑顺了,你的Agent才真正开始“活”起来,不然永远是玩具。