摘要:基于 TypeScript 开发 Harness 自定义工具插件,实现网页标题、描述、摘要抓取。经历三版迭代,解决模型不调用、异常崩溃、返回内容过长等问题,包含 Schema 参数、错误处理、Creator 热加载调试,附带完整可运行代码。
上周我在用 Agent 整理技术周报,批量丢入一批网页链接,希望它汇总文章要点。 发现 Agent 会直接调用 bash 执行 curl,把完整 HTML 全部拉取回来,再用 grep、sed 提取内容。部分网页体积很大,大量内容直接灌入上下文,token 消耗很高。遇到超时链接还会直接卡住整个会话。
Harness 内置web_search工具,它面向网页搜索场景,并不适合处理已知的具体 URL。bash+curl 虽然能实现需求,但开销太大。于是打算自己开发一个专用小工具。
需求梳理
工具目标很简单:传入 URL,输出网页标题、meta 描述、正文摘要。
一开始我考虑直接返回 JSON,但后面意识到,模型读取工具返回值本质是把内容直接放进会话上下文。如果返回 JSON,模型还需要额外做解析。直接输出格式清晰的纯文本,模型理解起来会更简单。
参数设计:url为必填项;max_length选填,用来控制摘要最大长度,默认 500。尽量减少参数数量,降低模型传参出错的概率。
第一版:代码可以运行,但模型不会调用工具
开发自定义插件需要切换到 Creator 模式,热加载可以大幅提升调试效率。新建fetch‑url‑meta文件夹,包含src/index.ts和cordis.yml配置文件。cordis.yml配置参考本系列 Day01,注意配置插件文件的绝对路径。
第一版基础实现代码如下:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'fetch-url-meta'
export const inject = ['http', 'tools']
export function apply(ctx: Context) {
ctx.tools.register({
name: 'fetch_url_meta',
description: '抓取网页信息',
parameters: {
type: 'object',
properties: {
url: { type: 'string' },
max_length: { type: 'number' },
},
required: ['url'],
},
async execute({ url, max_length = 500 }) {
const res = await ctx.http.get(url)
const html = res.data
const titleMatch = html.match(/<title>(.*?)<\\/title>/i)
const descMatch = html.match(
/<meta\\s+name=["']description["']\\s+content=["'](.*?)["']/i
)
const title = titleMatch ? titleMatch[1] : '无标题'
const description = descMatch ? descMatch[1] : '无描述'
// 粗暴地去掉所有HTML标签当正文
const text = html.replace(/<script[\\s\\S]*?<\\/script>/gi, '')
.replace(/<style[\\s\\S]*?<\\/style>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/\\s+/g, ' ')
.trim()
const preview = text.slice(0, max_length)
return `标题:${title}\\n描述:${description}\\n正文摘要:${preview}`
},
})
}
代码中注入http与tools两个服务。tools用于注册自定义工具;http是框架内置 HTTP 客户端,用法类似 axios,无需额外安装依赖,同时和运行时网络策略、代理、Cookie 管理打通。
配置完成启动 Harness,插件注册成功。但测试的时候发现,当传入网页链接,Agent 并不会调用这个新工具,依旧选择 bash + curl。
排查后定位到问题:工具的description描述写的过于笼统。工具描述不是给开发者阅读的文档,而是给模型做决策判断的依据。模型依靠工具名和描述,判断什么场景调用哪个工具。描述模糊,模型就不会选择该工具。
修改description,写清适用场景、输出内容以及不适用场景:
description: '当用户提供了一个具体的URL并希望了解该网页的内容时使用。返回网页的标题、描述和正文摘要,无需下载整个页面。不适用于搜索网页(应使用web_search)或下载文件。',
修改后重新测试,模型能够正常触发调用。工具代码写得再完善,如果模型不去调用,就发挥不了实际作用,因此工具描述需要投入足够精力。
第二版:工具可调用,但异常场景直接抛出堆栈
工具能够正常调用之后,又遇到不少异常场景。访问无效链接,ctx.http.get会直接抛出异常。框架不会崩溃,但模型拿到完整异常堆栈,无法理解错误原因,也不能做对应的处理。
遇到 302 重定向,默认不会跟随跳转,拿到空响应,解析后全部返回无标题无描述。传入 PDF 链接时,返回二进制内容,正则解析会输出大量乱码。
工具内部不要直接向外抛出异常,原始堆栈信息对模型没有意义。需要捕获异常,把错误信息转换成模型可读的自然语言,直接返回字符串。
增加完整异常处理逻辑:
async execute({ url, max_length = 500 }) {
let res
try {
res = await ctx.http.get(url, {
timeout: 10000,
maxRedirects: 5,
validateStatus: () => true, // 不抛异常,自己判断状态码
})
} catch (e: any) {
if (e.code === 'ECONNABORTED') {
return '错误:请求超时(10秒),该网站可能响应过慢或无法访问。请检查URL是否正确,或稍后重试。'
}
return `错误:无法访问该URL(${e.message})。请检查URL是否正确,或该网站是否需要特殊网络环境。`
}
if (res.status >= 400) {
return `错误:服务器返回状态码 ${res.status}。该页面可能不存在或需要认证。`
}
const contentType = res.headers['content-type'] || ''
if (!contentType.includes('text/html')) {
return `错误:该URL不是网页(Content‑Type: ${contentType}),无法提取标题和正文。可能是PDF、图片或其他文件。`
}
// … 下面的解析逻辑不变
}
关键点:设置超时时间避免长时间阻塞;开启重定向跟随;validateStatus关闭 HTTP 客户端对 4xx/5xx 自动抛错,业务代码自行判断状态码;区分超时与普通网络错误;校验Content‑Type过滤非 HTML 资源。
这里采用 return 返回错误文本,而不是 throw 抛出异常。无论执行成功还是失败,工具返回的全部内容都会送入会话上下文。返回可读提示后,模型就可以根据错误信息做出应对,比如询问用户链接是否有误。
第三版:返回值过大,摘要截取效果差
处理大型网页时,直接对完整 HTML 做标签替换,性能和内存开销比较大。 另外简单截取前 N 个字符效果并不理想,页面开头经常是导航栏、侧边栏、广告内容,真正的正文没有被截取到。
做两处优化:解析前先剔除nav、header、footer、aside这类非正文区块;文本截断尽量放在句子结束位置,避免把一句话拦腰切断。
const text = html
.replace(/<nav[\\s\\S]*?<\\/nav>/gi, ' ')
.replace(/<header[\\s\\S]*?<\\/header>/gi, ' ')
.replace(/<footer[\\s\\S]*?<\\/footer>/gi, ' ')
.replace(/<aside[\\s\\S]*?<\\/aside>/gi, ' ')
.replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')
.replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/\\s+/g, ' ')
.trim()
let preview = text.slice(0, max_length)
// 尽量在句子边界截断
const lastPeriod = Math.max(preview.lastIndexOf('。'), preview.lastIndexOf('. '), preview.lastIndexOf('\\n'))
if (lastPeriod > max_length * 0.6) {
preview = preview.slice(0, lastPeriod + 1)
}
同步处理 HTML 实体编码,防止模型读到转义字符。当原始文本长度超过上限,末尾追加…(内容已截断),明确告诉模型文本并不完整,防止把片段当成完整内容进行总结。
参数 Schema 踩坑记录
这里踩过一个耗费不少调试时间的坑。 第一版只在函数解构中设置max_length = 500,没有在 schema 定义默认值。模型有时候会传入字符串类型,部分场景可以隐式转换正常运行;一旦传入中文数字,就会直接引发异常。
参数 Schema 除了定义类型,建议补充参数说明,默认值写在 schema 内部。框架会依据 schema 完成参数校验,模型也能读取参数约束,减少传参错误。
最终参数配置:
parameters: {
type: 'object',
properties: {
url: {
type: 'string',
description: '要抓取的网页URL,必须是完整的http或https地址',
},
max_length: {
type: 'number',
description: '正文摘要的最大字符数,默认500。对于长文章可以适当增大,但不建议超过2000',
default: 500,
minimum: 100,
maximum: 5000,
},
},
required: ['url'],
},
配置minimum和maximum参数约束很有价值。模型如果传入超大数值,框架会在调用 execute 之前拦截,不会发起无效的 HTTP 请求。
Creator 模式调试
Creator 模式的热加载对插件开发效率提升很大。 开启 Creator 会话,修改保存代码即可自动重载,不需要重启 Harness,也不用新建会话。直接沿用现有上下文继续测试工具。对比标准模式反复重启重建会话,调试周期缩短很多。
日志会打印工具调用入参、返回结果、执行耗时。我之前遇到 title 输出异常的问题,依靠日志快速定位原因:title 标签存在跨行,正则.*?无法匹配换行字符,修改为[\\s\\S]*?解决。
完整代码
import type { Context } from '@deepseek-ai/cordis'
export const name = 'fetch-url-meta'
export const inject = ['http', 'tools']
export function apply(ctx: Context) {
ctx.tools.register({
name: 'fetch_url_meta',
description: '当用户提供了一个具体的URL并希望了解该网页的内容时使用。返回网页的标题、描述和正文摘要,无需下载整个页面。不适用于搜索网页(应使用web_search)或下载文件。',
parameters: {
type: 'object',
properties: {
url: {
type: 'string',
description: '要抓取的网页URL,必须是完整的http或https地址',
},
max_length: {
type: 'number',
description: '正文摘要的最大字符数,默认500。对于长文章可适当增大,但不建议超过2000',
default: 500,
minimum: 100,
maximum: 5000,
},
},
required: ['url'],
},
async execute({ url, max_length = 500 }) {
let res
try {
res = await ctx.http.get(url, {
timeout: 10000,
maxRedirects: 5,
validateStatus: () => true,
})
} catch (e: any) {
if (e.code === 'ECONNABORTED') {
return '错误:请求超时(10秒),该网站可能响应过慢或无法访问。请检查URL是否正确,或稍后重试。'
}
return `错误:无法访问该URL(${e.message})。请检查URL是否正确。`
}
if (res.status >= 400) {
return `错误:服务器返回状态码 ${res.status}。该页面可能不存在或需要认证。`
}
const contentType = res.headers['content-type'] || ''
if (!contentType.includes('text/html')) {
return `错误:该URL不是网页(Content‑Type: ${contentType}),无法提取标题和正文。`
}
const html = res.data as string
const titleMatch = html.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i)
const descMatch = html.match(
/<meta\\s+[^>]*name=["']description["']\\s+content=["']([\\s\\S]*?)["']/i
)
const title = (titleMatch?.[1] || '无标题').trim()
const description = (descMatch?.[1] || '无描述').trim()
const text = html
.replace(/<nav[\\s\\S]*?<\\/nav>/gi, ' ')
.replace(/<header[\\s\\S]*?<\\/header>/gi, ' ')
.replace(/<footer[\\s\\S]*?<\\/footer>/gi, ' ')
.replace(/<aside[\\s\\S]*?<\\/aside>/gi, ' ')
.replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')
.replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/\\s+/g, ' ')
.trim()
let preview = text.slice(0, max_length)
const lastBreak = Math.max(
preview.lastIndexOf('。'),
preview.lastIndexOf('. '),
preview.lastIndexOf('!'),
preview.lastIndexOf('?')
)
if (lastBreak > max_length * 0.6) {
preview = preview.slice(0, lastBreak + 1)
}
if (text.length > max_length) {
preview += '…(内容已截断)'
}
return `标题:${title}\\n描述:${description}\\n正文摘要:${preview}`
},
})
}
代码逻辑并不复杂,核心就是 HTTP 请求配合正则字符串处理。有了这个工具之后,Agent 拿到链接不会再完整拉取整个页面,直接获取精简摘要。
打包,分享插件
Harness 插件本质就是 npm 包,想要对外分享,编写package.json声明入口与元信息即可。
{
"name": "dsh-plugin-fetch-url-meta",
"version": "1.0.0",
"description": "Fetch URL metadata (title, description, content preview) for DeepSeek Harness",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": ["dsh-plugin", "harness", "url", "meta"],
"peerDependencies": {
"@deepseek-ai/cordis": "*"
}
}
包名带上dsh‑plugin前缀,方便其他人检索。编译完成发布 npm,其他人可以执行下面命令安装插件:
npx -y @deepseek-ai/dsh plugin –profile web add dsh-plugin-fetch-url-meta
注意:建议锁定版本,不要直接使用@latest,规避 pnpm 相关拦截问题。
总结
这个工具本身技术门槛不高。真正决定工具是否好用的,反而是功能之外的细节:工具描述、异常返回处理、参数约束、HTML 实体处理、句子边界截断。
开发 Agent 自定义工具,不能只完成基础业务逻辑。大模型不像人类,容错能力有限。工具描述写得模糊,模型就不会调用;直接抛出异常堆栈,模型无法处理;返回文本过长,会大量消耗 token。
开发工具要站在模型调用方的角度思考:什么场景调用、参数如何约束、返回什么内容、出错该怎么提示。这些都需要实际运行测试,观察模型的表现,再迭代优化。
本系列后续:Day05,聊聊土法视觉,在纯文本模型之上,借助 OCR 实现图片内容识别。
专栏:梅雅达编程笔记|DeepSeek Harness 实战系列 Day04 本文记录 Harness 插件开发过程中的踩坑与实践,欢迎订阅专栏,查看后续更新。







