我不是算法工程师,是写业务代码的。这里记录我怎么从"会跟 ChatGPT 聊天"走到"自己写出一个能真的动手干活的智能体",包括翻过的车。
顺带说一句,这个项目本身就是用 AI 写的——我用 DeepSeek、Codex、Claude 这些 AI 工具来开发和调试一个 AI 项目。这件事挺有意思,以后单独写一篇讲。
这篇讲三件基础功:思考怎么流式输出、问答时怎么传附件、工具怎么写怎么调。
一、起因:我不想再当搬运工了
我有个自己的博客,跑在 Typecho 上。写一篇文章的流程是这样的:先在聊天窗口跟模型聊选题、聊大纲,让它写一稿,然后复制到编辑器,自己调排版、找配图、填标签,最后去后台存草稿、检查、发布。
问题不在模型写得不好。问题是我全程在当搬运工。
模型不知道我的博客有哪些分类,不知道上一篇写了什么,不会自己插图,更不能直接存草稿。它是个很聪明的顾问,但它坐在我办公室外面,每次沟通都得我去传话。
所以我想试试,能不能让它直接坐进我的系统里干活。不是"帮我写段字",而是:
把上次那篇讲配置的文章续写一节,配一张系统架构的示意图,存成草稿等我确认。
这句话里藏了三件事:查历史、写内容、画图。想让聊天窗口一次做完,我得在中间当三次搬运工。那就造个智能体吧。
二、"智能体"到底智能在哪
我一开始天真地以为,智能体就是模型加一个好看的界面。真动手才发现,分界线在于它能不能自己动手改变外部世界。聊天窗口只能读我贴进去的东西,吐文字给我;而我的博客智能体能直接查数据库、自己存草稿传图发布,还会在回答我之前先自查一遍"这轮到底交出东西了没有"。
最后这一条最要命。能动手就意味着会犯错,而且错留在真实系统里。所以我还得给它加两道保障,一道管"它说做完了",一道管"它到底交回了什么"。少了这两道,它会很自信地告诉我"处理好了",而实际上什么都没发生。
但动手之前,我先纠结了一件事:Agent 那个"循环",要不要自己写?
那个循环大概长这样:
把消息发给模型 → 它说"我要调这个工具" → 我执行 → 把结果塞回去 → 它接着想
↑ │
└──────────────────────────────────────────────────────────────┘
直到它不再要工具,这一轮就算完了看起来不难,写起来全是坑:流式怎么接、工具并发怎么调度、上下文超了怎么压缩、会话怎么落盘、进程崩了怎么恢复、模型换了怎么办。我纠结了两天。这类循环要是从头自己写,光是把它写稳当就够我折腾很久,而我真正想做的其实是那个智能体本身。
那一刻我才想明白,我要做的不是"造一个 Agent",而是给一套现成的运行环境装上一个会干活的员工。分工一下子清楚了:模型调用与流式解析、工具调用与并发调度、会话落盘与崩溃恢复、上下文压缩、文件沙箱与审批、模型路由,这些底下的活儿都是现成的;我要写的是这个智能体是谁、会什么、结果怎么交回来。
这个划分后来一直是我的主轴:绝大部分精力花在"这个员工是谁、有什么规矩、干完怎么交货"上面,而不是花在怎么把模型调通。
代码写多了会发现,麻烦往往不是功能没实现,而是它留下的痕迹没收拾干净。所以还有一条规矩:每样东西用完都要能收干净。
三、一条很土但救命的原则
我给自己的第一条原则是:机制只实现一次,业务只写声明。
会话怎么开、消息怎么发、工具怎么注册、超时怎么算,这些每个智能体都一样的东西只写一份;它自己特有的部分,写成一个声明。这条原则我后来一直没破过。
落到代码上,一个智能体的"业务身份"就是个对象:
{
id: 'blog',
displayName: '博客智能体',
persona: 人设文本, // 我是谁、守什么规矩
tools: ctx => [ ...工具列表 ], // 我会干什么
redact: 文本脱敏, // 交出去前擦掉敏感信息
projectResult: 把这一轮结果整理成交回结论,
turnContext: 每轮动态资料, // 比如"现在有几张待确认的草稿"
}这份声明自己不注册任何东西,它只是"说",真正把它接上是另一处代码在做。我刻意这么分,是为了防止"声明"和"实现"各自跑偏。但代价是:另一边要是忘了接上,什么都不会发生,也不报错。这个坑我踩过两次,都是自己翻代码才发现的。
四、基础功之一:让思考流出来
用户在等回答时最焦虑的是"它到底在不在干活",所以我想把思考过程实时显示出来。
4.1 第一版很丑
最直觉的做法是模型吐一个字我转发一个字。我试了,效果惨不忍睹。
模型推理是碎的,一次几个字,页面疯狂重绘;推理中途可能失败重试,上一版不要的思路会混进来;最麻烦的是脱敏——我要求不显示手机号、身份证、内部记录 ID,而流式分片很可能正好把一个手机号切成两半发出去。
4.2 改了三个地方
第一,攒够 250 毫秒,发一份完整快照。思考内容按"步骤"在内存里累积,每 250 毫秒对外发一份完整快照,页面收到的是覆盖而不是追加。好处是页面重绘次数可控,而且快照是可替换的,万一发现前面某段不该显示,换掉就行。
第二,失败重试的思路整段扔掉。模型一次推理失败会重来,重来时旧的那段必须从快照里删掉。否则用户会看到两段自相矛盾的思路,还以为系统精神分裂。
第三,正文不逐字发,只停在安全边界上。
"他的手机号是138" + "00138000" + ",请联系"
↑ 不能发!手机号可能还没拼完,脱敏匹配不上
等到"," 才一起发 ──► 脱敏已经能完整匹配到号码代价是一整段没有边界符的内容(比如一大坨 JSON)会憋到最后才出现。我接受这个代价,宁可晚一点,也不要把没脱敏干净的东西发出去。
4.3 插曲:模型爱用英文思考
后来发现模型经常拿英文推理。我的用户是中文业务人员,看到满屏英文思考会以为系统坏了。于是我加了个"中文阅读副本":发现一段思考里英文字母占比太高,就另外发一次翻译请求,把英文思考翻成中文展示,翻过的存进数据库。
这里有三条我自己定的规矩:不动原始记录,翻译只作阅读副本另存,原始推理一个字不动;命中缓存就不再调模型,同一段思考翻过一次就存下来;带并发上限、超时和原文长度上限,防止它变成一个失控的额外开销。
4.4 一句大实话:人设管不住硬约束
我一开始想让模型"别用英文思考",就写进了人设。结果不稳定。
后来想明白了:人设能约束的是风格和选择,约束不了模型内部怎么想。所以"用中文回答、语气专业"可以写进人设;但"时间只查最近 2 天"必须由代码截断,写人设只是提醒;"不准调某个工具"要靠工具可见性限制,人设管不住;"不输出身份证号"必须代码脱敏,绝不能靠自觉。
这几条归成一句话,是我这一年最值钱的经验:凡是不能出错的,都不许写在人设里。
五、基础功之二:问答时上传附件
需求很简单,把一张图或一份文档发给智能体,让它基于内容回答。
我原以为发消息就是发个文本,真做起来发现,给模型的消息其实是一组内容块,文本、图片、文件可以混在一起。所以我在声明里留了个口子:这一轮往会话里塞什么,由我自己组合,默认是一条纯文本,需要附件时就拼多块。
这里有个工程坑:别把附件直接塞进消息体。我的做法分两步,附件先上传并登记,拿到自己的 ID、归属人、大小、类型,落在一个现成的附件服务里;发消息时只带引用,模型看到的是"这儿有段内容",而不是一坨几百 KB 的 base64。好处很实在:刷新页面历史还在,会话重放时附件不丢,同一个附件还能被多条消息复用。
图片更特殊一点,它既要给模型看,也要给用户看原图,所以我给它分了两个去处:给模型的走图片内容本身,进对话上下文;给页面的走图片地址,进展示层。这个"一份数据、两个去处"的思路,后来成了我处理大块数据的默认做法。
六、基础功之三:工具怎么写、怎么调
6.1 先拆一个常见误解
我一开始以为模型调工具是这样的:模型输出一段特殊格式的文本,我拿正则去解析。
完全不是。 现代模型 API 有原生的工具调用,我把工具清单(名字、说明、参数结构)一起发过去,模型返回结构化的调用请求,我执行完再把结果作为一条消息发回去。所以我要写的是工具定义,不是解析器。这一步有现成的工具系统可用,省了不少事:
defineTool({
name: 'blog_create_draft',
description: '把一篇文章存成草稿,等待用户确认后发布',
parameters: {
title: { type: 'string', required: true, description: '文章标题' },
content: { type: 'string', required: true, description: '正文 Markdown' },
tags: { type: 'array', description: '标签' },
},
execute: async (args) => { /* 真正干活:写我的数据库 */ },
})模型看到的是 description 和参数说明,它靠这些决定要不要调、怎么填。所以我写工具描述花的时间比写实现还多。
6.2 工具执行完,结果去哪
这一处我卡了很久。工具返回的东西,模型和页面看到的,不该是同一份。
拿查文章列表举例。一条记录里带着正文全文、标签、摘要、发布时间、评论数、附件清单,十条就是一大坨。全塞给模型会怎样?上下文被无关的字段占满,而且正文全文对"帮我看看哪几篇该更新了"这种判断毫无价值;页面又拿不到完整数据,列表和摘要渲染不出来。
最后我做成一份数据分两路出:给模型的只有它判断需要的字段,标题、发布时间、标签、字数;给页面的给完整记录,正文、附件、评论都在。模型只看到做判断需要的,页面拿到能渲染的。这是我最满意的一处设计,一刀同时解决了上下文太贵和页面画不出来两个问题。
6.3 注册和可见性,是两件事
工具写好了还得过两道关。第一道是登记,说明这个智能体一共有哪些工具,整体登记一次就行。第二道是可见性,限定这一次对话能用哪些工具,这一步必须做在每次会话上。
为什么要分两层?第一道关是"它到底提供了什么",第二道是"这一次对话里它能用什么",两者的范围不一样,混在一起早晚出错。第二道关还有个副作用我一开始没想到:要是一个工具压根没被注册,而我又在会话里限制了可见范围,模型手里就是空的,而且不报错。这个坑我已经踩过两次,都是自己翻代码才发现的。
6.4 工具描述就是说明书
写了几十个工具之后,我总结出几条。要说清"什么时候该用",不只是"这个工具做什么",因为模型最容易犯的错不是用错工具,是该用的时候没想起来用。要给出退路,"查不到就如实说明,不要换个参数反复重试"这一句直接砍掉了我大量无效调用。冲突的工具要说清优先级,比如"改稿必须用这个工具,别用新建再删的老办法"。最后,别让模型猜格式,参数写 yyyy-MM-dd HH:mm:ss 就比写"时间"好十倍。
七、能用了
到这一步,我的博客智能体已经能查我博客的数据、写稿改稿存草稿、传图配图发布、把思考用中文流式展示出来,会话落库刷新页面还在,每轮结束前还会自己核一遍有没有正文、有没有交回材料。
第一次用它把一整篇文章从选题做到存草稿、中间一次都没切换窗口的时候,我盯着屏幕看了好一会儿。以前这件事要在三个工具之间来回搬六七次,现在就是一段对话。
当然它还不完美。模型偶尔会忘掉人设里的某条规矩,工具描述写得不好它就想不起来用,长对话到后面它会把前面的细节记混。但这些都可以慢慢打磨,方向已经对了。
回头看我这一路,真正花时间的不是"让它写文章",那部分模型天生就会。花时间的是这些:怎么让它看到的资料是最新的,怎么让它调工具的时候不把上下文撑爆,怎么把敏感信息拦住不流出去,怎么在它做完之后确认它是真的做完了。
底下那些活儿都是现成的,我才腾得出手,把精力花在上面这些不那么性感、但决定能不能用的问题上。
附:本篇关键概念
人设(persona)是给模型的角色设定和规矩,管风格和选择,管不了硬约束。工具(tool)是给模型的一双手,走原生工具调用,不是文本解析。内容块指的是消息不只有文字,可以是文本、图片、文件多块组合。流式(streaming)是边生成边显示,我把思考做成节流快照,正文停在安全边界发。脱敏(redaction)是擦掉手机号、身份证、内部 ID,必须在代码里做。声明(definition)是智能体那份"我是谁、我会什么、结果怎么交"的配置。装配(assembly)是把声明变成真东西的过程:建存储、注册工具、开会话。