1. 为什么我要自己动手写一个智能体
1.1 从“玩具”到“工具”的落差
过去一年我陆陆续续试过不少智能体框架,从早期的 AutoGPT 到后来的各类 Agent 编排平台,说实话,演示视频都很惊艳,但真正落到日常工作中,能持续用下去的没几个。大部分框架要么依赖一大堆外部服务,要么配置复杂到让人失去耐心,要么就是“看起来很智能,实际用起来很笨”——你让它查个天气,它给你绕三圈调用五个工具,最后还答非所问。
我真正想要的东西其实很朴素:一个能挂在我日常用的聊天工具里、能理解自然语言、能调用我预设的几个工具、能记住上下文、代码量可控、出了问题我能自己改的智能体。不需要多模态,不需要花哨的界面,不需要复杂的编排引擎。就是那种“我发一句话,它帮我干活”的东西。
Hermes 式智能体的核心思路给了我很大启发——它强调用极简的循环结构驱动大模型,通过工具调用完成实际任务,而不是堆砌复杂的架构。我决定沿着这个思路,用最少的代码实现一个能接入飞书的版本。最终核心逻辑压缩到 100 行左右,剩下的都是配置和适配代码。
1.2 这个智能体到底能做什么
先把这个东西的能力边界说清楚,免得你抱有不切实际的期待。它本质上是一个“消息驱动的工具调用循环”,具体来说:
- 你在飞书里给它发一条消息,比如“帮我查一下今天北京的天气”
- 它把这条消息连同系统提示词一起发给大模型
- 大模型判断需要调用天气查询工具,返回一个工具调用请求
- 智能体执行这个工具,把结果再喂回给大模型
- 大模型根据工具返回的结果生成最终回复
- 回复通过飞书机器人发回给你
整个过程就是一个 while 循环,直到大模型不再请求调用工具为止。听起来简单,但这个循环能覆盖的场景非常多:查资料、做计算、读写文件、调用内部接口、定时提醒等等。关键在于你给它配了哪些工具。
适合谁来参考这篇内容?如果你是有一定编程基础、想快速搭一个自己能掌控的智能体、并且日常使用飞书作为沟通工具的开发者,那这篇东西就是写给你的。如果你完全不懂编程,可能需要先补一下 Python 基础和 HTTP 接口的基本概念。
1.3 为什么选飞书作为接入渠道
这里说一下选型逻辑。市面上能接入的聊天平台不少,我选飞书主要基于几个考虑:
第一,飞书的机器人开发文档相对清晰,自建应用的流程不复杂,个人开发者也能申请。第二,飞书的开放平台接口设计比较规范,消息收发、事件订阅这些都有标准的 HTTP 回调机制,不需要额外的长连接库。第三,飞书的消息卡片能力很强,后续如果想做交互式的按钮、表单,扩展空间大。第四,我日常工作流本身就在飞书上,接入之后不需要切换工具,使用成本最低。
当然,如果你用的是其他聊天工具,核心逻辑完全一样,只需要替换消息收发的适配层。智能体本身跟平台是解耦的,这一点在设计时就要注意。
2. 整体架构与核心设计思路
2.1 一百行核心代码的边界在哪里
先把“100 行核心代码”这个概念说清楚,免得产生误解。这 100 行指的是智能体的主循环逻辑:接收消息、调用大模型、处理工具调用、返回结果。不包括:
- 飞书的消息收发适配代码(大约 80 行)
- 工具函数的具体实现(每个工具 10 到 30 行不等)
- 配置文件和依赖管理
- 错误处理和日志
所以整个项目加起来大概 300 到 400 行,但真正体现“智能体”这个概念的,就是那 100 行左右的主循环。这样拆分的好处是,核心逻辑足够清晰,你读一遍就能理解智能体是怎么运转的,不会被各种适配代码干扰。
我见过太多项目把核心逻辑和平台适配、工具实现混在一起,导致想改一个地方要翻半天代码。分层设计不是过度工程,而是让自己以后能看懂自己写的东西。
2.2 消息驱动的循环结构
整个智能体的运转逻辑可以用一句话概括:收到消息就进入循环,循环里不断问大模型“你还需要调工具吗”,直到它说“不需要了”为止。
用伪代码表示大概是这样:
def handle_message(user_message, history): messages = build_messages(user_message, history) while True: response = call_llm(messages) if response.has_tool_calls: for tool_call in response.tool_calls: result = execute_tool(tool_call) messages.append(tool_result_message(result)) else: return response.content这个循环的精髓在于:大模型是决策者,代码只是执行者。你不需要写一堆 if-else 来判断用户想干什么,大模型会根据系统提示词和工具描述自己决定。这就是 Hermes 式智能体的核心思想——把决策权交给模型,把执行权留给自己。
为什么用 while 循环而不是递归?因为递归在工具调用链较长时容易栈溢出,而且调试起来不直观。while 循环配合一个最大迭代次数限制,既安全又好排查。
2.3 工具注册机制的设计
工具是智能体的手脚。没有工具,大模型只能聊天;有了工具,它才能干活。我的设计是每个工具用一个 Python 函数实现,通过装饰器注册到工具表里,同时自动生成工具的 JSON Schema 描述供大模型使用。
TOOLS = {} def tool(name, description, parameters): def decorator(func): TOOLS[name] = { "function": func, "schema": { "type": "function", "function": { "name": name, "description": description, "parameters": parameters } } } return func return decorator这样设计的好处是,新增一个工具只需要写一个函数加一个装饰器,不需要改任何核心逻辑。工具的描述直接写在装饰器里,大模型通过这个描述来判断什么时候该调用哪个工具。描述写得好不好,直接决定了工具调用的准确率,这一点后面会详细说。
2.4 上下文管理的取舍
上下文管理是个容易被忽视但很关键的问题。大模型的上下文窗口有限,如果每次对话都把全部历史塞进去,很快就会超限。我的做法是:
- 保留最近 N 轮对话(默认 10 轮)
- 系统提示词始终保留
- 工具调用的中间结果在当轮结束后丢弃,只保留最终回复
为什么不保留工具调用记录?因为那些中间结果对后续对话通常没有价值,反而会占用大量 token。比如查天气的工具返回了一长串 JSON,用户下一句问“那明天呢”,大模型只需要知道“刚才查过天气”就够了,不需要看到完整的 JSON 数据。
当然这是个取舍。如果你的场景需要大模型记住工具返回的具体数据,那就得保留。我建议先用丢弃策略跑一段时间,发现确实需要再改,不要一上来就做复杂的上下文压缩。
3. 飞书接入的完整实操过程
3.1 创建飞书自建应用的步骤
第一步是去飞书开放平台创建一个自建应用。这个过程不复杂,但有几个坑需要注意。
进入开发者后台,选择“创建企业自建应用”,填写应用名称和描述。名称随便起,但建议跟你实际用途相关,比如“我的助手”。创建完成后,你会拿到 App ID 和 App Secret,这两个东西后面要用。
接下来是配置权限。在“权限管理”页面,你需要开通以下权限:
im:message:获取与发送单聊、群组消息im:message.group_at_msg:接收群聊中@机器人消息im:message.p2p_msg:接收单聊消息
如果你还想让机器人能主动发消息,还需要im:message:send_as_bot权限。权限开通后需要发布版本才能生效,这一步别忘了。
然后是事件订阅。在“事件订阅”页面,你需要配置请求地址,也就是你的服务端接收飞书回调的 URL。这里有个关键点:飞书会先发一个验证请求过来,你的服务端需要正确响应 challenge 才能通过验证。很多人卡在这一步,后面会详细说怎么处理。
3.2 消息收发的适配层实现
飞书的消息回调是 HTTP POST 请求,body 是 JSON 格式。你需要做三件事:验证请求、解析消息、回复消息。
验证请求的处理逻辑是:飞书发过来的 JSON 里有一个challenge字段,你只需要原样返回这个值即可。注意要设置正确的 Content-Type 为application/json。
@app.route('/webhook', methods=['POST']) def webhook(): data = request.json if 'challenge' in data: return jsonify({'challenge': data['challenge']}) # 处理消息 handle_event(data) return jsonify({'code': 0})解析消息时要注意,飞书的事件格式有两层嵌套:外层是事件类型,内层是具体内容。消息事件的关键字段在event.message.content里,是一个 JSON 字符串,需要二次解析。
回复消息需要先获取 tenant_access_token,然后用这个 token 调用发送消息接口。token 有有效期,建议缓存起来,不要每次发消息都重新获取。
def get_tenant_token(): if cache.get('token'): return cache['token'] resp = requests.post( 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', json={'app_id': APP_ID, 'app_secret': APP_SECRET} ) token = resp.json()['tenant_access_token'] cache['token'] = token cache['expire'] = time.time() + 7000 return token注意:tenant_access_token 的有效期是 7200 秒,建议提前 200 秒刷新,避免边界情况导致请求失败。
3.3 大模型接口的对接细节
我用的是 OpenAI 兼容的接口格式,这样切换模型供应商时只需要改 base_url 和 model 名称。核心是构造正确的请求体:
def call_llm(messages): resp = requests.post( f'{BASE_URL}/chat/completions', headers={'Authorization': f'Bearer {API_KEY}'}, json={ 'model': MODEL_NAME, 'messages': messages, 'tools': [t['schema'] for t in TOOLS.values()], 'tool_choice': 'auto' } ) return resp.json()['choices'][0]['message']这里有几个参数值得说明。tools字段传入所有注册工具的 schema,大模型会根据这些描述决定是否调用。tool_choice设为auto表示让模型自己决定,如果你想强制它必须调用某个工具,可以设为具体值,但一般不建议。
响应解析时要注意,如果模型决定调用工具,返回的 message 里会有tool_calls字段,是一个数组,可能包含多个工具调用。你需要逐个执行,然后把结果按顺序追加到 messages 里。
3.4 工具调用的完整链路演示
拿“查天气”这个场景走一遍完整链路,你就明白整个流程了。
用户发消息:“北京今天天气怎么样”。适配层收到后,构造 messages 数组,包含系统提示词和用户消息。调用大模型,模型返回一个 tool_call,name 是get_weather,arguments 是{"city": "北京"}。
智能体解析这个 tool_call,找到对应的函数执行,得到结果{"temp": 25, "condition": "晴"}。然后构造一条 role 为tool的消息,带上 tool_call_id 和结果内容,追加到 messages 里。再次调用大模型,这次模型不再请求工具,直接返回“北京今天晴天,气温 25 度”。
整个过程中,用户只发了一条消息,但背后发生了两次大模型调用和一次工具执行。这就是智能体的工作方式——对用户透明,但内部有完整的决策链路。
4. 工具设计与提示词工程
4.1 工具描述怎么写才准确
工具描述的质量直接决定大模型调用的准确率。我踩过的坑是:描述写得太简单,模型不知道什么时候该用;描述写得太复杂,模型又容易混淆。
好的工具描述应该包含三个要素:这个工具做什么、什么时候用、参数是什么意思。比如查天气的工具:
@tool( name="get_weather", description="查询指定城市当天的天气情况,包括温度和天气状况。当用户询问天气相关问题时使用。", parameters={ "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如北京、上海" } }, "required": ["city"] } ) def get_weather(city): # 实现略 pass注意 description 里明确写了“当用户询问天气相关问题时使用”,这就是在告诉模型调用时机。参数描述里给了示例,帮助模型正确填充参数。
我实测下来,描述里加上调用时机的说明,工具调用的准确率能提升不少。不加的话,模型有时候会该调不调,或者不该调乱调。
4.2 系统提示词的关键作用
系统提示词是智能体的“人格设定”,它决定了智能体的行为风格和边界。我的系统提示词大概是这样:
你是一个乐于助人的智能助手,可以通过调用工具来帮助用户完成任务。 当用户的问题需要实时信息或计算时,优先调用相应工具。 如果工具调用失败,如实告知用户,不要编造结果。 回答要简洁直接,不要过度解释。这几句话看似简单,但每一句都有用意。“优先调用工具”是防止模型偷懒直接编答案。“不要编造结果”是防止工具失败时模型胡诌。“简洁直接”是控制回复长度,避免啰嗦。
你可以根据自己的需求调整这个提示词。比如你想让它更活泼,可以加“用轻松的语气回复”;你想让它更严谨,可以加“不确定的信息要明确说明”。
4.3 多工具场景下的选择逻辑
当你有多个工具时,模型需要判断该调用哪个。这时候工具描述的区分度就很重要。我建议:
- 工具名称要有辨识度,不要用
tool1、tool2这种 - 功能相近的工具,在描述里明确区分使用场景
- 参数设计尽量统一,减少模型的认知负担
举个例子,如果你同时有“查天气”和“查空气质量”两个工具,描述里要写清楚:天气工具用于温度、降水等问题,空气质量工具用于 PM2.5、AQI 等问题。不要指望模型自己猜。
如果工具数量超过 10 个,建议做分组或者用更高级的路由机制。不过对于个人使用场景,5 到 8 个工具基本够用了,太多反而增加模型判断的难度。
4.4 工具执行的安全边界
工具是智能体跟外部世界交互的通道,必须设置安全边界。我的做法是:
- 文件操作限制在指定目录内,不允许访问系统目录
- 网络请求设置超时,避免卡死
- 敏感操作(如删除文件)需要二次确认
- 工具执行结果做长度截断,避免超长内容撑爆上下文
这些限制不是不信任大模型,而是防止意外。大模型有时候会“过度热情”,你问它一个问题,它可能调用一堆工具去查。设置边界能避免这种情况造成实际损害。
提示:工具函数里一定要做异常捕获,返回结构化的错误信息而不是直接抛异常。否则一个工具报错会导致整个循环崩溃。
5. 常见问题与排查实录
5.1 飞书回调验证失败怎么办
这是最常见的第一个坑。飞书配置事件订阅时,会发一个验证请求,你的服务端必须正确返回 challenge 值。常见失败原因:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 提示 URL 验证失败 | 服务端没有返回 challenge | 检查代码是否正确解析并返回 |
| 提示请求超时 | 服务端响应太慢 | 确保验证逻辑在 3 秒内返回 |
| 提示格式错误 | Content-Type 不对 | 设置为 application/json |
| 验证通过但收不到消息 | 权限未发布 | 去权限管理页面发布版本 |
我遇到过一次验证失败,排查半天发现是服务端框架自动加了 BOM 头,导致飞书解析失败。这种问题很隐蔽,建议用 curl 手动测试一下接口返回的原始内容。
5.2 大模型不调用工具怎么破
有时候你明明配了工具,但模型就是不用,直接凭自己的知识回答。这种情况通常是三个原因:
第一,工具描述不够明确。模型不知道什么时候该用这个工具。解决办法是在 description 里明确写调用时机。
第二,系统提示词没有强调。在系统提示词里加一句“需要实时信息时优先调用工具”,能明显改善。
第三,模型本身的能力问题。有些小模型对工具调用的支持不好,换个模型试试。我用下来,支持 function calling 的模型在这方面表现都不错。
如果还是不行,可以临时把tool_choice设为具体工具名,强制模型调用。但这只是调试手段,不要长期这么用。
5.3 工具调用结果太长导致上下文溢出
这个问题在调用返回大量数据的工具时特别明显。比如你调用一个搜索工具,返回了 5000 字的网页内容,直接塞进 messages 里,下一轮调用就可能超限。
我的处理方式是:在工具函数里做结果截断,只返回关键信息。比如搜索工具只返回前三条结果的标题和摘要,而不是全文。如果确实需要完整内容,让模型分多次调用,每次取一部分。
另一个技巧是在系统提示词里加一句“工具返回结果可能被截断,如需完整信息请分步获取”。这样模型知道结果不完整时,会主动分步调用。
5.4 消息重复回复的问题
飞书的事件订阅有个机制:如果你没有在指定时间内返回 200,飞书会重试推送。如果你的处理逻辑比较慢,就可能出现同一条消息被处理两次、回复两次的情况。
解决办法是加一个消息去重机制。飞书的事件里有一个event_id字段,你可以用一个集合记录最近处理过的 event_id,重复的直接跳过。
processed_events = set() def handle_event(data): event_id = data.get('header', {}).get('event_id') if event_id in processed_events: return processed_events.add(event_id) # 处理消息注意这个集合要定期清理,不然会一直增长。简单做法是只保留最近 1000 个,或者用带过期时间的缓存。
5.5 排查问题的通用思路
遇到问题时的排查顺序建议是:
- 先看日志,确认请求有没有到达服务端
- 确认请求内容格式是否正确
- 检查大模型调用是否成功,返回内容是什么
- 检查工具执行是否报错
- 检查回复消息是否成功发送
我习惯在每个关键节点打日志,包括收到的原始请求、构造的 messages、大模型返回、工具执行结果、发送的回复。这样出问题时能快速定位是哪一环出了问题。
提示:日志里不要打印完整的 API Key 和用户隐私信息,调试完成后记得清理敏感日志。
6. 扩展方向与个人经验
6.1 可以继续加的东西
这个智能体的基础框架搭好之后,扩展空间很大。我目前想到的几个方向:
定时任务:加一个定时器,让智能体在指定时间主动发消息。比如每天早上推送天气和日程。实现方式是用一个后台线程检查任务队列,到时间就调用发送消息接口。
多轮对话记忆:目前只保留最近 10 轮,如果想让它记住更久之前的信息,可以加一个向量数据库做长期记忆。用户提到的重要信息存进去,需要时检索出来。
消息卡片交互:飞书的消息卡片支持按钮和表单,可以让智能体发带按钮的消息,用户点击后触发特定操作。这在做审批、选择这类场景时很有用。
多用户隔离:目前是单用户设计,如果要给多个人用,需要按用户 ID 隔离上下文和工具权限。这个改动不大,主要是加一个用户维度的存储。
6.2 我踩过的几个坑
说几个实际踩过的坑,希望能帮你省点时间。
第一个坑是 token 缓存没做,每次发消息都重新获取 tenant_access_token,结果飞书那边有频率限制,发多了就被限流。后来加了缓存,问题解决。
第二个坑是工具函数没有做超时控制,有一次调用一个外部接口卡住了,整个智能体就挂在那里不动。后来给所有网络请求加了 timeout 参数。
第三个坑是系统提示词写得太长,占了大量 token,导致实际对话空间被压缩。后来精简到几句话,效果反而更好。提示词不是越长越好,关键是每句话都有用。
第四个坑是没做消息去重,飞书重试导致重复回复。这个前面说过了,加个 event_id 去重就行。
6.3 关于代码量的一点体会
最后说回“100 行核心代码”这个事。我一开始也觉得 100 行能写出什么,但实际做下来发现,核心逻辑确实可以很精简。关键在于把复杂度放在该放的地方:工具实现可以复杂,适配层可以复杂,但主循环必须简单。
主循环简单的好处是,你随时能看懂它在干什么,出问题能快速定位,想改行为只需要调提示词或加工具,不用动核心逻辑。这种“核心极简、外围灵活”的架构,我觉得是个人开发者做智能体最务实的路线。
不要追求一开始就做一个大而全的框架,先跑通最小闭环,然后根据实际需求一点点加。我这个小智能体现在每天帮我处理查资料、做计算、定时提醒这些杂事,虽然不复杂,但确实省了不少切换工具的时间。这就够了。