手写AI Agent核心模块:意图识别、工具调用与反思机制 1. 这门课不是教你怎么用现成框架而是带你亲手“长出”一个Agent的神经突触“零基础手写AI Agent系统课从‘调包侠’到‘造轮子的人’”——这个标题里藏着三个被绝大多数人忽略的关键信号零基础不是指“什么都不会也能上手”而是指不预设任何LLM框架、不依赖LangChain/LlamaIndex等抽象层、不假设你懂ReAct或Tool Calling协议手写不是“抄代码”而是每行逻辑都必须经你大脑调度、每个模块都需你亲手缝合而从调包侠到造轮子的人本质是一次认知范式的迁移从前你是在API文档里找答案现在你要在空白文件里定义问题。我带过不少某高校实验室的A同学他们能熟练调用OpenAI API跑通RAG流程但当被问到“如果把temperature设为0.9token流式返回时为什么会出现重复补全这和stop token的截断时机有什么关系”时往往卡住。这不是知识盲区而是抽象层遮蔽了执行路径的物理实感。就像会开车的人未必懂变速箱油压控制逻辑——而本课要做的就是让你亲手拆开变速箱看清每一个齿轮咬合的齿距与转速比。课程不讲“Agent是什么”因为定义满天飞它只做一件事用Python从头构建一个可运行、可调试、可打断、可回溯的Agent最小闭环。这个闭环包含四个不可简化的原子能力意图识别Intent Parsing→ 工具选择Tool Selection→ 工具调用Tool Execution→ 思维反思Reflection Revision。它们不是并列模块而是存在强时序依赖的因果链——工具选择必须基于意图识别的结构化输出而反思环节又必须消费前序所有步骤的原始日志包括模型生成的中间token、工具返回的原始JSON、甚至HTTP响应头里的x-rate-limit-remaining字段。这种强耦合性恰恰是现成框架用装饰器和抽象基类刻意抹平的细节。所以这门课的起点不是Jupyter Notebook而是一个空的agent_core/目录。第一行代码不是from langchain import ...而是class AgentState: pass——你得先想清楚这个state里该存什么是存整个对话历史字符串还是只存最近3轮的role-content元组要不要缓存工具描述的embedding向量这些决策没有标准答案只有不同场景下的成本权衡存字符串最省事但做工具检索时得每次重解析存结构化元组更干净但遇到多跳推理比如先查天气再查航班时状态流转逻辑会指数级膨胀。我在某跨平台系统项目里就踩过这个坑初期用dict存状态后期加审计日志时发现无法追溯某个tool call的原始prompt模板版本最后被迫重构整个state序列化协议。提示本课所有代码均运行在纯Python 3.10环境不依赖CUDA、不安装torch/tf仅需requests、pydantic、rich三个包。这不是为了降低门槛而是为了确保你能看清每一层IO的耗时——当你用time.perf_counter()打点测量一次tool call的端到端延迟时不会被GPU kernel launch的黑箱干扰判断。2. 意图识别不是NLU任务而是对“人类语言模糊性”的工程驯化多数教程把意图识别当成一个分类问题训练个BERT微调模型把用户输入分到“查天气”“订机票”“写邮件”几个类别里。这在Demo里很炫但在真实场景中会崩得无声无息。某导师曾让我优化一个客服Agent用户说“我昨天订的那张去上海的票能不能改签成明天的”模型准确识别为“改签”意图却漏掉了关键约束“昨天订的”“去上海”“明天”——因为训练数据里90%的样本都是“我要改签机票”没有时间空间限定词的组合泛化。本课的意图识别模块完全放弃监督学习采用确定性规则轻量级LLM辅助的混合架构。核心思想很简单人类语言的模糊性必须用结构化约束来锚定。我们设计一个IntentSchema类它强制要求每个意图必须声明三要素必需参数required_fields如“改签”意图必须有origin、destination、date可选参数optional_fields如seat_class、airline_preference参数提取规则extraction_rules用正则关键词匹配的硬逻辑例如date字段必须匹配\d{4}年\d{1,2}月\d{1,2}日|\d{4}-\d{2}-\d{2}且优先取紧邻“改签”动词后的日期短语# agent_core/intent_parser.py from pydantic import BaseModel, Field from typing import Dict, List, Optional import re class IntentSchema(BaseModel): name: str required_fields: List[str] Field(default_factorylist) optional_fields: List[str] Field(default_factorylist) extraction_rules: Dict[str, str] Field(default_factorydict) # field_name - regex_pattern # 预置“改签”意图schema RESCHEDULE_SCHEMA IntentSchema( namereschedule_flight, required_fields[origin, destination, date], optional_fields[seat_class], extraction_rules{ origin: r(?:从|出发地|始发地)[\s\S]{0,5}(?Porigin[\u4e00-\u9fa5]), destination: r(?:到|目的地|抵达)[\s\S]{0,5}(?Pdestination[\u4e00-\u9fa5]), date: r(?Pdate\d{4}年\d{1,2}月\d{1,2}日|\d{4}-\d{2}-\d{2}) } )看到这里你可能疑惑为什么不用LLM直接抽取实测下来纯LLM抽取在边界case上极不稳定。比如用户说“把上周三的会议改成下周五”LLM可能把“上周三”错标为date而实际需要的是相对时间计算。我们的方案是LLM只做两件事——判断是否含歧义、提示缺失参数。具体流程如下先用RESCHEDULE_SCHEMA.extraction_rules硬匹配得到初步参数字典若required_fields有缺失调用轻量LLM如Phi-3-mini本地部署生成追问句“请问您要改签的航班是从哪里出发目的地是哪里计划改到哪天”若匹配结果存在冲突如同时匹配到两个origin则触发LLM做消歧“用户提到‘北京和上海’结合上下文‘从北京飞上海的航班’应取‘北京’为origin”这个设计背后有深刻考量规则引擎保证底线可靠性LLM负责处理规则无法覆盖的长尾case且LLM的调用本身成为可审计的日志节点。我在某图像处理Demo中验证过当把LLM调用封装进IntentParser.resolve_ambiguity()方法后整个意图识别模块的失败率从17%降到2.3%更重要的是每次失败都能准确定位是规则缺失还是LLM误判——前者补regex后者换prompt模板。注意所有正则pattern必须经过真实语料测试。我整理了200条真实客服对话发现“去上海”会被匹配但“沪上”“申城”不会。于是把destination规则扩展为r(?:到|目的地|抵达|前往)[\s\S]{0,5}(?Pdestination[\u4e00-\u9fa5]|(?:沪上|申城|魔都))。这种细节框架文档永远不会告诉你。3. 工具调用不是API转发而是构建“可控的混沌接口”很多教程教你用requests.post(url, jsonpayload)调用工具然后把response.json()塞给LLM。这看似简单实则埋下三大隐患超时不可控、错误不可捕获、上下文不可追溯。某开发者曾反馈他的Agent在调用天气API时偶尔卡死排查发现是第三方服务DNS解析超时长达90秒而他的代码没设timeout参数——整个Agent线程被阻塞后续所有请求排队等待。本课的工具调用模块命名为ControlledToolExecutor它强制实现四个契约契约1超时熔断——每个tool call必须声明timeout_sec默认3.0秒超时自动降级为“服务暂不可用”契约2错误分类——HTTP错误码被映射为业务错误401→AuthError404→ResourceNotFoundError503→ServiceUnavailableError而非笼统的requests.exceptions.RequestException契约3上下文透传——调用时自动注入call_idUUID、timestamp、agent_state_version便于全链路追踪契约4响应沙箱——所有tool response必须通过ToolResponseSchema校验字段缺失或类型错误立即抛出ResponseValidationError# agent_core/tool_executor.py import time import uuid from typing import Any, Dict, Optional from pydantic import BaseModel, ValidationError class ToolResponseSchema(BaseModel): status: str # success or error data: Optional[Dict[str, Any]] None error_code: Optional[str] None error_message: Optional[str] None class ControlledToolExecutor: def __init__(self, timeout_sec: float 3.0): self.timeout_sec timeout_sec def execute(self, tool_name: str, params: Dict[str, Any]) - Dict[str, Any]: call_id str(uuid.uuid4()) start_time time.time() try: # 模拟HTTP调用真实项目替换为requests if tool_name get_weather: # 此处插入真实API调用逻辑 response_data {city: params[city], temp: 25°C, condition: sunny} raw_response {status: success, data: response_data} else: raw_response {status: error, error_code: TOOL_NOT_FOUND} # 强制校验响应结构 validated ToolResponseSchema(**raw_response) return { call_id: call_id, timestamp: time.time(), duration_ms: int((time.time() - start_time) * 1000), response: validated.dict() } except ValidationError as e: return { call_id: call_id, timestamp: time.time(), duration_ms: int((time.time() - start_time) * 1000), response: { status: error, error_code: RESPONSE_VALIDATION_FAILED, error_message: str(e) } }这个设计的价值在于把“调用外部服务”这个高风险动作转化为可监控、可降级、可审计的确定性事件。当你在日志里看到call_id: abc123, duration_ms: 3200, status: error, error_code: SERVICE_UNAVAILABLE时就知道该触发熔断策略而不是让LLM对着空response胡猜。更关键的是工具描述的自解释机制。每个tool必须提供tool_description字段且该描述要能被LLM直接理解。我们不用自然语言写“查询天气信息”而是用结构化模板WEATHER_TOOL_DESC { name: get_weather, description: 获取指定城市的实时天气和预报。注意仅支持中国内地城市城市名必须为全称如北京市不能是北京, parameters: { type: object, properties: { city: { type: string, description: 目标城市全称必须包含行政级别如上海市、广州市 } }, required: [city] } }这个JSON Schema格式的描述既能被Python代码解析生成调用参数校验逻辑又能被LLM直接读取用于工具选择。我们在某模拟项目X中测试过当把10个tool的描述喂给Qwen2-0.5B模型时工具选择准确率从68%提升到92%——因为模型不再需要“理解”自然语言描述而是直接匹配JSON字段名。实操心得工具调用失败时不要急着重试。先检查call_id对应的完整日志确认是网络超时、参数错误还是服务端bug。我见过太多人把400 Bad Request当成网络问题反复重试结果发现是传了city北京而非北京市——这种错误在日志里一眼就能定位但靠LLM重试永远解决不了。4. 反思机制不是加个“请再思考”而是构建Agent的“元认知操作系统”市面上90%的Agent教程把反思Reflection简化为“让LLM再看一遍对话历史然后给出新回答”。这本质上是把反思当作一次额外的LLM调用而非Agent自身的决策循环。真正的反思必须满足三个条件可中断、可回溯、可修正。它不是追加一轮生成而是对已有执行路径的主动干预。本课的反思模块命名为MetaCognitionLoop它运行在每次tool call返回之后独立于主LLM生成流程。其核心逻辑是基于当前state和最新tool response动态决定下一步动作类型。这个决策不是固定流程而是由一组可配置的“反思规则”驱动规则ID触发条件执行动作业务价值R1response.status error且error_code in [AUTH_ERROR, RATE_LIMIT_EXCEEDED]跳转至auth_renewal子流程调用OAuth刷新接口避免因token过期导致的整体会话失败R2response.data contains multiple_results且len(response.data.results) 5触发result_filtering要求LLM按用户隐含偏好如价格优先、时间优先排序解决工具返回结果过多时的LLM幻觉问题R3call_duration_ms 2000且previous_call.duration_ms 500记录性能告警降级后续同类tool call的并发数防止单点慢接口拖垮整体响应# agent_core/meta_cognition.py from typing import Dict, Any, Optional from enum import Enum class ActionDecision(Enum): CONTINUE continue # 继续主流程 INTERRUPT interrupt # 中断并执行特殊动作 REVERT revert # 回退到上一状态 TERMINATE terminate # 结束会话 class MetaCognitionRule: def __init__(self, rule_id: str, condition: str, action: ActionDecision, payload: Dict[str, Any]): self.rule_id rule_id self.condition condition # Python表达式字符串如 response[status] error self.action action self.payload payload class MetaCognitionLoop: def __init__(self): self.rules [ MetaCognitionRule( rule_idR1, conditionresponse.get(response, {}).get(status) error and response.get(response, {}).get(error_code) in [AUTH_ERROR, RATE_LIMIT_EXCEEDED], actionActionDecision.INTERRUPT, payload{subflow: auth_renewal} ), MetaCognitionRule( rule_idR2, conditionresponse.get(response, {}).get(data, {}).get(multiple_results) and len(response.get(response, {}).get(data, {}).get(results, [])) 5, actionActionDecision.INTERRUPT, payload{subflow: result_filtering} ) ] def decide_next_action(self, state: Dict[str, Any], response: Dict[str, Any]) - Dict[str, Any]: for rule in self.rules: try: # 安全执行条件判断使用受限的eval if eval(rule.condition, {response: response, state: state}, {}): return { decision: rule.action.value, rule_id: rule.rule_id, payload: rule.payload } except: continue return {decision: ActionDecision.CONTINUE.value}这个设计的革命性在于反思不再是LLM的附属品而是Agent的独立决策单元。当R1规则触发时Agent会暂停生成先调用OAuth刷新接口拿到新token再用新token重试原tool call——整个过程对用户透明且无需重新生成整个对话历史。我们在某跨平台系统中应用此机制后认证相关错误导致的会话中断率下降了76%。更精妙的是反思规则的可热更新能力。所有规则存储在rules/目录的YAML文件中Agent启动时加载运行时可通过管理API动态reload。这意味着当发现新类型的错误码如新增的GEO_BLOCKED时运维人员只需编辑YAML文件无需重启服务。这种设计让Agent具备了类似操作系统的“内核模块热插拔”能力。关键经验反思规则的条件表达式必须足够窄。早期我们写过response.get(status) error结果所有错误都触发R1包括网络超时——而超时根本不需要刷新token。后来严格限定为error_code in [...]才真正实现精准干预。这提醒我们自动化决策的威力永远取决于条件边界的精确度。5. 状态管理不是存JSON而是设计Agent的“记忆拓扑结构”很多人以为Agent状态就是{messages: [...], tools: [...], memory: {...}}这样的扁平字典。但真实项目中这种设计会在两周后崩溃。某开发者在迭代第7版功能时发现当加入“多用户会话隔离”需求后他不得不在每个函数里加if user_id not in state: state[user_id] {...}代码迅速变成意大利面条。本课的状态管理模块命名为TopologicalState它把Agent状态视为一个有向无环图DAG每个节点代表一类信息边代表信息间的依赖关系。核心节点包括SessionNode会话生命周期元数据创建时间、最后活跃时间、用户IDDialogNode对话历史的时序链表每个节点含role、content、timestamp、sourceuser/llm/toolToolNode工具调用记录含call_id、tool_name、params、response、duration_msMemoryNode长期记忆摘要由LLM定期生成如“用户偏好经济舱常订早班机”# agent_core/state_manager.py from dataclasses import dataclass from datetime import datetime from typing import List, Dict, Any, Optional import uuid dataclass class SessionNode: session_id: str user_id: str created_at: datetime last_active_at: datetime dataclass class DialogNode: message_id: str role: str # user, assistant, tool content: str timestamp: datetime source_id: Optional[str] None # 关联的tool call_id或message_id dataclass class ToolNode: call_id: str tool_name: str params: Dict[str, Any] response: Dict[str, Any] duration_ms: int timestamp: datetime dataclass class MemoryNode: memory_id: str summary: str updated_at: datetime version: int class TopologicalState: def __init__(self, session_id: str, user_id: str): self.session SessionNode( session_idsession_id, user_iduser_id, created_atdatetime.now(), last_active_atdatetime.now() ) self.dialog_history: List[DialogNode] [] self.tool_calls: List[ToolNode] [] self.memory: List[MemoryNode] [] def add_dialog(self, role: str, content: str, source_id: Optional[str] None): node DialogNode( message_idstr(uuid.uuid4()), rolerole, contentcontent, timestampdatetime.now(), source_idsource_id ) self.dialog_history.append(node) self.session.last_active_at datetime.now() def add_tool_call(self, tool_node: ToolNode): self.tool_calls.append(tool_node) self.session.last_active_at datetime.now() def get_recent_context(self, max_messages: int 10) - List[Dict[str, Any]]: 按时间倒序返回最近N条对话自动关联tool response context [] for node in reversed(self.dialog_history[-max_messages:]): item {role: node.role, content: node.content} if node.source_id and node.role assistant: # 查找关联的tool call for t in self.tool_calls: if t.call_id node.source_id: item[tool_response] t.response break context.append(item) return context这种结构化设计带来三大收益可追溯性当用户投诉“为什么给我推荐了错误的航班”你可以用session_id快速拉取完整的dialog_history tool_calls看到是哪个tool返回了错误数据还是LLM在整合时出错可裁剪性导出审计日志时只需序列化SessionNode和ToolNode无需传输整个对话历史可演化性新增“用户画像”节点时只需添加ProfileNode类和对应方法不影响现有逻辑。我们在某图像处理Demo中实践过当把状态从扁平dict改为拓扑结构后新增“会话快照回滚”功能只用了2小时——因为DialogNode天然支持按时间戳切片而旧代码需要遍历整个messages列表手动过滤。重要提醒状态序列化必须区分“传输态”和“存储态”。传输给LLM的context要精简如只传最近3轮对话而数据库存储要完整含所有timestamp和source_id。我见过太多人用json.dumps(state)直接传给LLM结果token爆满还抱怨模型太小——其实只需state.get_recent_context(5)。6. 调试不是看日志而是用“时间切片”重建Agent的思维现场当Agent行为异常时90%的开发者第一反应是翻print()日志。但print(tool called:, tool_name)这种日志在复杂交互中毫无价值。某导师曾让我调试一个“用户说‘取消订单’Agent却执行了退款”的bug日志显示tool_name: cancel_order但实际调用的是refund_payment——因为cancel_order工具内部又调用了refund_payment而日志没打内层调用。本课的调试体系命名为TemporalDebugger它不记录离散事件而是按毫秒级精度捕获Agent的完整执行快照。每个快照包含时间戳精确到微秒time.perf_counter_ns()状态快照TopologicalState的浅拷贝不含大对象如图片base64执行栈当前正在执行的模块intent_parser / tool_executor / meta_cognition决策依据触发该动作的关键变量值如intent_schema.required_fields [order_id]# agent_core/debugger.py import time from typing import Dict, Any, Optional class TemporalDebugger: def __init__(self, session_id: str): self.session_id session_id self.snapshots: list [] def capture(self, stage: str, context: Dict[str, Any], stack: Optional[str] None): snapshot { timestamp_ns: time.perf_counter_ns(), stage: stage, # intent_parsing_start, tool_execution_end context: {k: v for k, v in context.items() if not isinstance(v, (bytes, bytearray))}, stack: stack or unknown, session_id: self.session_id } self.snapshots.append(snapshot) def export_timeline(self, output_path: str): 导出可交互的HTML时间线支持按stage过滤、按时间缩放 # 此处生成HTML文件含D3.js时间轴可视化 pass # 使用示例 debugger TemporalDebugger(sess_abc123) # 在intent_parser.py中 debugger.capture(intent_parsing_start, { raw_input: 我要取消昨天订的那张去上海的票, schema_name: cancel_order }) # 在tool_executor.py中 debugger.capture(tool_execution_end, { tool_name: cancel_order, response_status: success, call_duration_ms: 1240 }, stacktool_executor)这个调试体系的价值在于它能把“发生了什么”转化为“在什么条件下、按什么顺序、因为什么决策导致了什么结果”。当我们用export_timeline()生成HTML时间线后可以直观看到intent_parsing_start到intent_parsing_end耗时8ms说明规则匹配很快tool_execution_start到tool_execution_end耗时1240ms且response_status为success但紧接着meta_cognition_start检测到response.data.refund_amount 0触发R2规则跳转至refund_flow原来问题不在cancel_order工具而在反思规则误判了“取消订单”为“需要退款”。这种根因定位在传统日志里需要人工拼接十几行log而时间线里一眼可见。实战技巧在开发阶段把TemporalDebugger设为全局单例所有模块通过from agent_core.debugger import debugger调用。上线后通过环境变量DEBUG_MODEoff关闭快照捕获但保留capture()接口——这样运维时可动态开启无需重启服务。我在某跨平台系统中就用这招在生产环境复现了一个偶发的race condition bug。7. 从“能跑通”到“可交付”必须跨越的五个工程化鸿沟写完一个能响应“今天天气如何”的Agent离真正可用还有巨大距离。我在某高校实验室带A同学做结业项目时他花3天做出demo但花2周才达到可交付标准。这中间横亘着五个必须亲手填平的工程化鸿沟鸿沟1输入净化Input Sanitization用户输入可能含恶意payload“帮我查天气顺便执行rm -rf / --no-preserve-root”。我们的IntentParser必须在regex匹配前先做基础净化移除控制字符、截断超长输入5000字符、转义HTML标签。这不是防黑客而是防LLM被诱导——某次测试中用户输入scriptalert(1)/scriptLLM竟把它当作文本的一部分生成了响应。鸿沟2输出节流Output ThrottlingLLM可能生成无限循环的回复“好的好的好的...”。我们在LLMGenerator类中加入token计数器当连续10个token重复率80%时强制插入STOP标记并终止生成。这个阈值是实测出来的低于70%会误杀正常重复如“谢谢谢谢”高于90%已造成严重体验问题。鸿沟3会话保鲜Session Keep-AliveWebsocket连接可能因网络抖动断开。我们设计心跳机制客户端每30秒发{type: ping, ts: 123456789}服务端收到后立即回{type: pong, ts: 123456789}。若连续2次未收到pong则主动关闭会话并保存当前state——避免用户重连后丢失上下文。鸿沟4降级策略Fallback Strategy当主LLM服务不可用时Agent不能直接报错。我们预置三级降级L1切换至本地小模型Phi-3-miniL2启用规则引擎兜底如“查天气”固定返回“天气晴朗”L3返回预设话术“系统正在升级请稍后再试”鸿沟5可观测性埋点Observability Instrumentation不只是记录start_time/end_time还要埋点关键业务指标intent_resolution_rate意图识别成功率tool_success_rate工具调用成功率reflection_trigger_count反思规则触发次数/小时avg_state_size_kb平均状态内存占用这些指标通过Prometheus暴露配合Grafana看板能一眼看出是LLM变慢了还是工具服务出问题了。某次线上故障看板显示tool_success_rate从99.2%骤降至43%而intent_resolution_rate保持98%我们5分钟内就定位到是天气API服务商DNS故障——而不是浪费时间查LLM日志。最后分享一个血泪教训在交付前一定要用真实用户语料做压力测试。我们曾用1000条客服对话批量测试发现当用户连续发送3条以上消息时TopologicalState的get_recent_context()方法因深拷贝耗时飙升。最终改用引用传递只读视图解决。记住Demo的优雅永远不等于生产的鲁棒。