联网能力:让AI看见更广阔的世界
——CogitoAgent开发实战(四)
📖 本文是专栏的第四篇。前三篇我们让AI学会了思考、学会了操作文件,但它仍然被困在你的电脑里——它不知道今天有什么新闻,不知道某个网站写了什么,不知道网上有什么资源。这一篇,我们给AI装上“眼睛”,让它能看见互联网上的信息。
📌 从一个场景开始
想象你的助理很能干,但你问他“今天有什么大新闻?”他回答:“我不知道,我没联网。”
你会不会觉得这个助理缺了点啥?
本地AI也有同样的问题。它能读你电脑里的文件,能帮你整理文件夹,但它对“外部世界”一无所知。
AI需要三种联网能力:
| 能力 | 场景 | 类比 |
|---|---|---|
| 搜索 | “帮我查一下最新的AI论文” | 像你用百度/Google |
| 浏览 | “打开这个链接给我看看” | 像你点击链接 |
| 抓取 | “这个网页里写了什么?” | 像你阅读文章 |
这一篇,我们逐个实现这三种能力。
一、搜索:让AI能“查资料”
1.1 问题:AI怎么搜索?
AI本身不能上网。它需要程序帮它去搜索,然后把搜索结果告诉它。
流程是这样的:
AI说:“帮我搜索AI新闻” ↓ 程序调用搜索API ↓ API返回搜索结果 ↓ 程序把结果整理成文字 ↓ AI“看到”结果,继续回答关键问题:搜索API从哪里来?
1.2 搜索API的选择
CogitoAgent使用了Moark的搜索API(/web-search-v2)。但这不是唯一的方案,你可以换成任何搜索服务:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Moark API | 开箱即用,与LLM同一供应商 | 需要付费 |
| Bing Search API | 微软官方,结果质量高 | 需要申请密钥 |
| Google Custom Search | 覆盖面广 | 配置复杂 |
| SerpAPI | 聚合多种搜索引擎 | 价格较高 |
| 本地爬虫 | 免费 | 需要自己维护,容易被封 |
设计要点:CogitoAgent把搜索API封装成可配置的,你可以替换成任何提供HTTP接口的搜索服务。
1.3 搜索API的调用
constAPI_URL="https://api.moark.com/v1/web-search-v2";constAPI_TOKEN="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";constheaders={"Content-Type":"application/json","Authorization":`Bearer${API_TOKEN}`}asyncfunctionquery(data){constresponse=awaitfetch(API_URL,{headers,method:"POST",body:JSON.stringify(data)});returnresponse.json();}query({"content":"搜索查询内容","model":"search","search_recency_filter":"根据网页发布时间进行筛选","search_site_filter":"指定站点搜索条件,仅在设置的站点中进行内容搜索,每行一个站点"}).then((response)=>{console.log(JSON.stringify(response));});1.4 关键设计:搜索结果的格式化
API返回的搜索结果通常是复杂的JSON结构:
{"snippets":[{"title":"2025年AI十大突破","url":"https://example.com/ai-news","content":"今年AI领域发生了..."},// 更多结果...]}把这个JSON直接丢给AI,AI能理解,但有三个问题:
- 太冗长:JSON有很多括号、引号,占用大量token
- 不够直观:AI需要解析JSON结构才能提取信息
- 难以阅读:人类调试时眼睛疼
所以我们需要一个格式化函数,把JSON转成易读的文本:
functionformatSearchResults(raw){letoutput=`搜索关键词:${query}\n\n`;// 尝试提取结果列表constresults=raw.snippets||raw.results||raw.organic_results||raw.web;if(Array.isArray(results)){for(leti=0;i<results.length;i++){constitem=results[i];consttitle=item.title||item.name||'无标题';consturl=item.url||item.link||'';constsnippet=item.snippet||item.content||item.description||'';output+=`[${i+1}]${title}\n`;if(url)output+=`${url}\n`;if(snippet)output+=`${snippet}\n\n`;}}returnoutput;}格式化后的输出:
搜索关键词: AI新闻 [1] 2025年AI十大突破 https://example.com/ai-news 今年AI领域发生了... [2] OpenAI发布新模型 https://example.com/openai OpenAI近日发布了...这样AI一眼就能看懂每条结果的核心信息。
1.5 思考题:为什么要格式化?
想象一下,如果直接返回JSON:
{"snippets":[{"title":"2025年AI十大突破","url":"https://...","content":"..."}]}AI需要:
- 解析JSON结构
- 找到
snippets数组 - 遍历每个元素,提取
title、url、content
虽然AI能做到,但这个过程消耗“思考时间”和token。格式化成纯文本后,AI可以直接使用,不需要额外解析。
设计原则:给AI的数据,越接近人类可读的自然语言越好。
二、浏览:在浏览器中打开网页
2.1 场景
用户说:“帮我打开百度。”
AI应该能启动你的默认浏览器,打开指定的网址。
2.2 实现:调用系统命令
不同操作系统打开浏览器的命令不同:
| 系统 | 命令 |
|---|---|
| Windows | start "" "url" |
| macOS | open "url" |
| Linux | xdg-open "url" |
CogitoAgent当前只实现了Windows版本:
// src/agent/tools/web.jsasyncfunctionbrowse(url){returnnewPromise((resolve)=>{// Windows:使用 start 命令exec(`start "" "${url}"`,(error)=>{if(error){resolve({success:false,error:`打开链接失败:${error.message}`});}else{resolve({success:true,data:`已在浏览器中打开:${url}`});}});});}注意start ""的写法:start命令的第一个参数是窗口标题,我们传空字符串,第二个参数才是URL。
2.3 为什么用exec而不是spawn?
exec把整个命令放在一个字符串里,适合简单命令。spawn更适合参数复杂的场景。对于start “” “url”这种命令,exec足够。
2.4 跨平台实现
如果要支持Mac和Linux,可以这样写:
asyncfunctionbrowse(url){returnnewPromise((resolve)=>{letcommand;if(process.platform==='win32'){command=`start "" "${url}"`;}elseif(process.platform==='darwin'){command=`open "${url}"`;}else{command=`xdg-open "${url}"`;}exec(command,(error)=>{if(error){resolve({success:false,error:`打开链接失败:${error.message}`});}else{resolve({success:true,data:`已在浏览器中打开:${url}`});}});});}process.platform返回当前操作系统:
win32:Windowsdarwin:macOSlinux:Linux
2.5 设计决策:打开还是抓取?
browse和fetchPage的区别:
| 工具 | 行为 | 适用场景 |
|---|---|---|
browse | 打开浏览器,用户自己看 | 用户想看网页内容 |
fetchPage | 程序抓取内容,AI读给用户 | AI需要分析网页内容 |
用户说“帮我打开这个链接” → 用browse
用户说“这个网页里写了什么” → 用fetchPage
三、抓取:让AI能“阅读”网页
3.1 问题:AI怎么读取网页内容?
browse只是打开浏览器,AI自己看不到网页内容。要让AI“读”网页,程序需要:
- 下载网页的HTML
- 解析HTML,提取正文
- 把正文交给AI
3.2 下载网页:fetch API
Node.js 18+ 原生支持fetch,不需要安装额外库:
constresponse=awaitfetch(url,{headers:{'User-Agent':'Mozilla/5.0...'// 有些网站需要模拟浏览器}});consthtml=awaitresponse.text();为什么要设置User-Agent?
有些网站会拒绝非浏览器的请求(返回403)。设置User-Agent模拟浏览器,可以绕过这种限制。
3.3 解析HTML:Cheerio
Cheerio是一个服务端版的jQuery。语法几乎一样,但不需要浏览器环境。
import*ascheeriofrom'cheerio';const$=cheerio.load(html);consttitle=$('title').text();constparagraphs=$('p').text();Cheerio vs 正则解析
| 方案 | 优点 | 缺点 |
|---|---|---|
| 正则 | 快,无依赖 | 难以处理复杂HTML,容易出错 |
| Cheerio | 强大,类似jQuery | 需要安装,稍慢(但可接受) |
对于抓取网页,Cheerio是标准选择。
3.4 提取正文:不只是取所有文本
一个网页包含很多无关内容:导航栏、广告、侧边栏、评论区、版权信息……
如果我们简单地把所有文本都提取出来,AI会读到大量垃圾信息:
导航:首页 产品 关于我们 广告:点击领取优惠券 正文:这篇文章主要讲... 评论区:张三说好,李四说不好 版权:©2025 某某公司我们需要正文提取——只提取文章的核心内容。
策略一:优先找语义标签
现代网页常用语义化的HTML5标签:
// 优先取 article 或 mainletcontentElement=$('article').first();if(contentElement.length===0){contentElement=$('main').first();}if(contentElement.length===0){contentElement=$('body');}策略二:移除干扰元素
// 删除这些标签及其内容$('script, style, nav, header, footer, aside, .ad, .sidebar, .comment').remove();策略三:提取段落
constparagraphs=contentElement.find('p, h1, h2, h3, h4, h5, h6, li');consttexts=[];paragraphs.each((_,el)=>{consttext=$(el).text().trim();if(text&&text.length>10){// 过滤太短的文本texts.push(text);}});constcontent=texts.join('\n');3.5 提取链接
有时候,用户需要知道网页里有哪些链接:
constlinks=[];$('a[href]').each((_,el)=>{consthref=$(el).attr('href');consttext=$(el).text().trim();if(href&&text&&href.startsWith('http')){// 只取外部链接links.push({text,url:href});}});3.6 大小限制
网页可能非常大。一篇长文章可能有几万字,加上HTML标签,原始HTML可能达到几MB。
我们需要限制输出大小:
constMAX_CONTENT=3000;// 3000字符letcontent=extractedText;if(content.length>MAX_CONTENT){content=content.substring(0,MAX_CONTENT)+'\n\n[内容过长已截断...]';}3.7 完整实现
asyncfunctionfetchPage(url){try{// 1. 下载HTMLconstresponse=awaitfetch(url,{headers:{'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}});if(!response.ok){return{success:false,error:`HTTP${response.status}:${response.statusText}`};}consthtml=awaitresponse.text();// 2. 解析HTMLconst$=cheerio.load(html);// 3. 提取标题consttitle=$('title').text().trim()||$('h1').first().text().trim()||'无标题';// 4. 移除干扰元素$('script, style, nav, header, footer, aside, .ad, .sidebar, .comment').remove();// 5. 定位正文容器letbody=$('article').first();if(body.length===0)body=$('main').first();if(body.length===0)body=$('body');// 6. 提取段落constparagraphs=body.find('p, h1, h2, h3, h4, h5, h6, li');consttexts=[];paragraphs.each((_,el)=>{consttext=$(el).text().trim();if(text&&text.length>10){texts.push(text);}});letcontent=texts.join('\n');// 7. 截断过长内容if(content.length>3000){content=content.substring(0,3000)+'\n\n[内容过长已截断...]';}// 8. 提取链接(最多10个)constlinks=[];$('a[href]').each((_,el)=>{consthref=$(el).attr('href');consttext=$(el).text().trim();if(links.length>=10)returnfalse;if(href&&text&&href.startsWith('http')){links.push({text,url:href});}});// 9. 组装输出letoutput=`标题:${title}\n\n正文:\n${content}`;if(links.length>0){output+='\n\n相关链接:\n';output+=links.map(l=>`-${l.text}:${l.url}`).join('\n');}return{success:true,data:output};}catch(error){return{success:false,error:`抓取失败:${error.message}`};}}3.8 局限性:动态网页
当前实现只能抓取静态网页——即服务器直接返回完整HTML的页面。
很多现代网站是动态渲染的:服务器返回一个空壳,JavaScript再去加载内容。对于这类网站,fetchPage只能抓到空壳。
解决方案:
- 使用Puppeteer(无头浏览器)来渲染JavaScript
- 但Puppeteer很重(下载Chromium),会增加项目体积
CogitoAgent选择了简单方案:只支持静态网页。对于动态网站,可以用browse打开浏览器让用户自己看。
四、配置与开关
4.1 为什么需要开关?
不是所有用户都想用搜索功能。有些人可能:
- 没有搜索API密钥
- 担心隐私(不想把搜索词发给第三方)
- 只需要本地功能
所以搜索应该可以关闭。
4.2 配置结构
{"search":{"enabled":true,"baseURL":"https://api.moark.com/v1/web-search-v2","recencyFilter":"","siteFilter":""}}| 字段 | 含义 |
|---|---|
enabled | 是否启用搜索(false时调用search会返回错误) |
baseURL | 搜索API地址(空则自动拼接) |
recencyFilter | 时间过滤,如“day”“week”“month” |
siteFilter | 站点过滤,如“github.com” |
4.3 在工具中检查开关
asyncfunctionsearch(query){constcfg=loadConfig();if(!cfg.search||cfg.search.enabled===false){return{success:false,error:'搜索功能未启用,请在配置中开启'};}// 继续执行搜索...}这样,如果用户没配置搜索API或者主动关闭了搜索,AI会收到明确的错误提示。
五、三个工具的协同
三个联网工具各自解决不同问题,但可以协同工作:
用户:帮我查一下最新的AI新闻,然后打开最有趣的那篇 AI: 1. [TOOL] search("2025年AI新闻") [/TOOL] → 得到结果列表 2. 分析结果,选出最有趣的一篇 3. [TOOL] fetchPage("https://example.com/ai-news") [/TOOL] → 读取文章内容,摘录给用户 4. [TOOL] browse("https://example.com/ai-news") [/TOOL] → 在浏览器中打开,让用户自己看这就是工具组合的威力——AI可以串联多个工具完成复杂任务。
六、设计决策回顾
| 决策 | 原因 |
|---|---|
| 搜索API可配置 | 不同用户用不同服务商 |
| 搜索结果要格式化 | 节省token,AI理解更快 |
| browse用系统命令 | 简单可靠,不依赖额外库 |
| fetchPage用Cheerio | 轻量级,语法熟悉 |
| 只抓取静态网页 | 避免引入Puppeteer的巨大体积 |
| 搜索有开关 | 尊重用户隐私和选择 |
七、小结
| 工具 | 做什么 | 核心技术 |
|---|---|---|
search | 联网搜索 | fetch + 搜索API + 格式化 |
browse | 打开浏览器 | exec + 系统命令 |
fetchPage | 抓取网页 | fetch + Cheerio + 正文提取 |
核心设计原则:
- 搜索结果要格式化,不要让AI解析JSON
- 网页抓取要提取正文,不要一股脑全给
- 给用户关闭搜索的选项
- 工具可以串联使用
下一篇预告:终端UI
我们将深入terminal.js,看看:
- 如何用ANSI颜色代码实现彩色输出
- 思考区、内容区、工具区的分区展示
- 流式输出的实时渲染
- readline如何实现“按Enter打断”
如果这篇文章对你有帮助,欢迎 ⭐Star 支持一下开源项目!
👉 https://gitee.com/cnt-code/cogito-agent 👈