我那个桌面应用里挂了个 AI 助手,走的是 DeepSeek 的接口。开始我以为难点在调接口,接完才发现接口是整件事里最简单的一段,真正花时间的是工具调用和上下文,这两块占了我改这东西八成的时间。
工具调用不是模型帮你干活
先把概念说清楚,很多人卡在这。模型没有能力读你的文件、查你的数据库,它只会回一个结构化的结果:我要调用哪个工具、参数是什么。真正执行的是你自己写的代码,执行完把结果拼回对话里,模型再接着往下说。
所以工具能干什么完全由你定,模型只是决定什么时候调、传什么参数。下面是一个工具定义的骨架:
{
"type": "function",
"function": {
"name": "search_notes",
"description": "在本地笔记里按关键词搜索,返回匹配的标题和摘要,不返回全文",
"parameters": {
"type": "object",
"properties": {
"keyword": { "type": "string", "description": "搜索词,中文或英文都行" },
"limit": { "type": "integer", "enum": [5, 10, 20], "description": "返回几条" }
},
"required": ["keyword"]
}
}
}
这里面 name、description、parameters 三样东西的质量,直接决定模型选工具的准确率。写的时候要当成给人看的说明书,不是给机器看的字段名。
工具描述写歪了,模型就乱调
我最早的描述写的是「读取本地文件」。结果模型经常在只需要标题的时候把整篇内容读出来,一次就把上下文塞掉一大块。改成「读取本地文件的指定行范围,行号从 1 开始,不传则只返回前 50 行」之后,它就开始老实传行号了。
几个具体的经验。工具名别起得抽象,getData、doThing 这种名字模型只能靠猜,search_notes、read_file_lines 这种它一看就知道什么时候用。参数字段要写单位,「timeout」模型会传 30,你得写清楚是秒还是毫秒。可选的取值范围写进 enum,比在描述里写一句「可选值是 5、10、20」管用得多。
还有一点,工具别给太多。我一开始把十几个工具全挂上,模型的选择准确率明显下来了。现在只挂当前场景用得上的那几个,切换场景再换一套。
最贵的地方是一次对话会连环调用
一次对话不是一问一答就结束。模型调工具,结果回给它,它看了结果可能再调一个,再回给它。每一轮的工具结果都留在上下文里,越滚越大。这是账单涨得最快的地方,也是回答质量开始下降的地方,因为噪声太多。
我干过一件蠢事:让一个工具直接返回整张表,两千多行。那一轮之后上下文吃掉一大半,模型回答开始丢细节,钱也花得快。后来定了三条限制,问题就不明显了。
第一条,工具内部先筛一遍再返回。数据库里有二十个字段,模型只需要三个,那就只返回那三个,别把整行丢过去让它自己找。
第二条,结果要截断,而且要告诉模型截断了。返回前 200 行,末尾加一句「已省略 1832 行,需要更多请加参数」。不告诉它,它会以为看到的就是全部。
第三条,单轮对话的工具调用次数设上限。到了上限就不再执行,直接告诉它「没有更多信息了,用现有的回答」。不加这条,模型有时候会在同一件事上反复试探,一直调到超时。
报错别抛异常,原样回给模型
工具执行失败的时候,有两种做法:抛异常中断对话,或者把错误信息当成工具结果返回给模型。我选后者。
把「文件不存在」或者「参数 limit 超出范围」原样回给它,它下一轮多半会换个参数再试,这个链路是通的。抛异常的话整轮对话就断了,用户看到的是助手突然不说话,体验很差。
但要防死循环。同一个工具、同样的参数连续失败两次,就别再让它试了,直接把错误显示给用户。我吃过这个亏,模型会固执地重复同一个错误调用,把次数配额烧完。
记忆只做会话内
会话内的记忆和跨会话的偏好是两件事。我只做了前者:这次对话里说过什么、调过哪些工具、结论是什么,关掉窗口就没了。
不做跨会话的理由很实际。桌面应用里的长期记忆很容易把旧结论带到新问题上,模型会拿着三个月前的判断回答今天的问题,而且它不会告诉你这是旧结论。真要做长期记忆,得让用户看得见、能删,不然就是给自己埋雷。
哪些事别交给它
确定性的事别绕一圈。读文件、算数、格式化字符串,写代码三十行就能解决,让模型做只会更慢更贵,还多一个出错的可能。
我现在的分法很干脆:界面操作和本地数据读写全是我自己的代码,模型只负责两件事,判断接下来该做什么,以及把结果用自然语言说清楚。这两件事它确实比我写规则强,其余的交出去是给自己找麻烦。
如果模型给的建议要动用户数据,中间一定加一道确认。它不是每次都对,而数据删了就没了。