Java/Go后端手撸原生Agent(第二篇):Pydantic结构化输出改造,工程化解决工具死循环问题

发布时间:2026/7/22 5:25:51

Java/Go后端手撸原生Agent(第二篇):Pydantic结构化输出改造,工程化解决工具死循环问题 上篇链接Java/Go后端手撸原生Agent”我“Java/Go后端开发者、有点时间想自己琢磨想入门Agent但不想堆砌框架、希望理解底层原理的研发正文前言上篇文章我们基于纯Python原生代码不依赖LangChain/LangGraph从零搭建了一套基础ReAct智能体实现了工具抽象、短期记忆、裸HTTP调用大模型。上篇完整工程环境配置、LLM客户端、工具基类、文本解析ReAct、主循环调度。本文作为续集完成两大核心工程级升级抛弃脆弱的文本分割解析使用Pydantic实现结构化JSON输出对标厂商标准Function Call修复原生Agent高频Bug工具重复调用死循环采用状态机工程化方案。前置说明完全复用上篇所有基础文件env_loader.py、memory.py、tools/工具包、llm_client.py基础版本新增文件agent/schema.pyPydantic实体、agent/structured_parser.py结构化解析器删除旧文本解析器parser.py核心改造main.py主循环、消息组装规范、死循环防护逻辑。一、拓展1Pydantic结构化输出改造替代文本ReAct解析1.1 改造思路后端视角文本分割解析硬伤LLM输出换行、注释、markdown会直接解析崩溃容错极低Pydantic等价Java POJO / Go Struct强类型校验、自动序列化/反序列化开启模型response_formatjson_object强制返回标准JSON对齐OpenAI/通义/DeepSeek原生Function Call协议注部分模型可能不支持该字段双分支实体工具调用动作、任务完成最终回答二选一输出。1.2 新建结构化模型 agent/schema.pyfrompydanticimportBaseModel,FieldfromtypingimportDict,Union# 场景1需要调用工具classToolAction(BaseModel):thought:strField(description推理思考过程)action:strField(description工具名称无工具固定填 FINISH)params:DictField(description工具入参json对象无参数传空{})# 场景2任务结束直接返回最终答案classFinishResponse(BaseModel):final_answer:strField(description无需调用工具整理后给用户的最终回答)# 联合类型LLM输出二选一AgentOutputToolAction|FinishResponse1.3 改造LLM客户端 llm_client.py支持JSON强输出新增json_mode参数控制结构化输出开关底层透传response_formatfrompydanticimportBaseModelfromenv_loaderimportLLMConfigimportrequestsclassResponseFormat(BaseModel):type:strclassChatRequest(BaseModel):model:strmessages:list[dict[str,str]]temperature:floatresponse_format:ResponseFormat|NoneNonedefchat_completion(messages:list[dict[str,str]],json_mode:boolFalse# 是否开启结构化输出)-str:headers{Authorization:fBearer{LLMConfig.API_KEY},Content-Type:application/json}reqChatRequest(modelLLMConfig.MODEL_NAME,messagesmessages,temperature0.1)# 开启json强制输出ifjson_mode:req.response_formatResponseFormat(typejson_object)bodyreq.model_dump()resprequests.post(f{LLMConfig.BASE_URL}/chat/completions,headersheaders,jsonbody)resp.raise_for_status()returnresp.json()[choices][0][message][content]#测试入口if__name____main__:reschat_completion([{role:user,content:你好}],json_modeTrue)print(res)1.4 结构化解析器 agent/structured_parser.py替代旧文本解析器自动JSON反序列化为Pydantic实体自带参数校验importjsonfromagent.schemaimportAgentOutput,ToolAction,FinishResponseclassStructuredParser:staticmethoddefparse_json(raw_json_str:str)-AgentOutput|None: 解析大模型返回的JSON字符串自动区分 ToolAction / FinishResponse try:datajson.loads(raw_json_str)ifactionindata:returnToolAction(**data)eliffinal_answerindata:returnFinishResponse(**data)else:returnNoneexceptExceptionase:print(f结构化解析失败{str(e)}原始输出{raw_json_str})returnNone1.5 规范消息组装角色隔离修复字符串拼接Bug上篇存在致命不规范写法将system规则全部历史对话拼接为一段字符串塞进单条system消息角色边界丢失模型极易混淆user/工具观测数据。工程标准写法第一条消息固定全局System规则记忆内每条对话独立追加区分user/assistant/system(工具观测)# 标准消息组装代码messages[{role:system,content:SYSTEM_PROMPT}]# 第二步追加所有历史对话、工具观测记录每条独立role隔离messages.extend(memory.get_raw_dict_list())同步微调短期记忆agent/memory.py保证输出标准OpenAI消息结构fromtypingimportTypedDictclassMessage(TypedDict):role:strcontent:strclassShortMemory:def__init__(self):self.history:list[Message][]defadd_user(self,content:str):用户的输入self.history.append({role:user,content:content})defadd_assistant(self,content:str):self.history.append({role:assistant,content:content})defadd_observation(self,content:str):工具返回的观察结果self.history.append({role:system,content:content})defget_all(self)-list[Message]:returnself.history.copy()defget_raw_dict_list(self)-list[dict[str,str]]:# 直接返回原生dict数组适配LLM入参return[{role:m[role],content:m[content]}forminself.history]1.6 重写系统提示词约束模型输出行为不再混入对话上下文仅定义全局规则、工具、输出格式上下文完全交给messages数组管理SYSTEM_PROMPT 你是支持工具调用的智能助手必须仅输出纯JSON禁止额外文字、Markdown、换行注释。 可用工具 calculator数学计算器参数expr为数学表达式例{expr:(10020)*5} 严格遵守执行规则 1. 首次缺少数值时调用calculator获取计算结果 2. 一旦收到system角色的工具观测结果已算出数字**禁止再次调用任何工具**必须直接输出final_answer总结答案 3. 两种输出格式严格二选一 - 需要调用工具时{thought:推理过程,action:工具名称,params:{expr:表达式}} - 已有工具计算结果、无需工具{final_answer:把计算结果整理成自然语言回答用户} 二、线上运行Bug复现工具无限重复调用死循环2.1 问题现象执行计算(100 20) * 5模型第一次调用计算器拿到结果后无视提示词约束持续重复调用同一工具耗尽最大循环次数才终止不会输出final_answer。完整日志特征第一轮用户提问 → 调用计算器 → 存入system观测记录第二轮上下文携带观测结果模型偶尔输出final_answer但代码未立刻return循环继续后续轮次上下文叠加历史assistant回答模型逻辑混乱反复执行工具直到max_loop5结束。2.2 根因工程层面非单纯提示词问题核心逻辑漏洞解析到FinishResponse仅写入记忆无return终止循环无显式任务状态标记全靠模型输出控制流程模型不稳定直接失效缺少多层前置拦截防护仅靠提示词约束容错率极低未检测记忆中已存在最终回答仍继续执行LLM推理。三、工程化根治方案状态机3.1 设计思路后端标准状态机思想新增任务状态枚举显式区分运行/完成状态驱动流程命中FinishResponse立即return终止整个函数循环耗尽兜底逻辑读取已生成的历史回答避免无结果返回可不做真实遇到这样说明我们的处理流程还是有问题。3.2 完整重写 run_agent 主循环 main.pyfromagent.memoryimportShortMemoryfromenumimportEnumfromagent.schemaimportFinishResponse,ToolActionfromagent.structured_parserimportStructuredParserfromllm_clientimportchat_completionfromtools.base_toolimportBaseToolfromtools.calculatorimportCalcTool# 1. 新增状态枚举工程化状态机替代隐式类型判断classAgentTaskState(Enum):RUNNINGrunningFINISHEDfinished# 注册所有可用工具tool_list:list[BaseTool][CalcTool()]tool_map{t.name:tfortintool_list}# 系统提示词规定ReAct输出格式SYSTEM_PROMPT 你是支持工具调用的智能助手必须仅输出纯JSON禁止额外文字、Markdown、换行注释。 可用工具 calculator数学计算器参数expr为数学表达式例{expr:(10020)*5} 严格遵守执行规则 1. 首次缺少数值时调用calculator获取计算结果 2. 一旦收到system角色的工具观测结果已算出数字**禁止再次调用任何工具**必须直接输出final_answer总结答案 3. 两种输出格式严格二选一 - 需要调用工具时{thought:推理过程,action:工具名称,params:{expr:表达式}} - 已有工具计算结果、无需工具{final_answer:把计算结果整理成自然语言回答用户} defrun_agent(user_query:str):memoryShortMemory()memory.add_user(user_query)max_loop5foriinrange(max_loop):# 标准规范消息组装固定system在前记忆消息在后 # 第一步根系统提示词全局规则、工具定义、输出JSON约束独立system消息messages[{role:system,content:SYSTEM_PROMPT}]# 第二步追加所有历史对话、工具观测记录每条独立role隔离messages.extend(memory.get_raw_dict_list())# 调试打印完整上下文print( 当前完整上下文 )formsginmessages:print(msg)# 调用LLM开启结构化JSON输出llm_raw_jsonchat_completion(messages,json_modeTrue)parse_resStructuredParser.parse_json(llm_raw_json)ifparse_resisNone:returnf模型输出格式解析失败原始内容{llm_raw_json}# 分支1任务完成直接返回最终答案ifisinstance(parse_res,FinishResponse):memory.add_assistant(parse_res.final_answer)task_stateAgentTaskState.FINISHEDreturnparse_res.final_answer# 分支2执行工具调用流程ifisinstance(parse_res,ToolAction):tool_nameparse_res.action tool_paramsparse_res.paramsprint(f【推理思考】{parse_res.thought})print(f【工具调用】name{tool_name}, params{tool_params})tooltool_map.get(tool_name)ifnottool:obsf异常不存在工具{tool_name}else:obstool.run(tool_params)print(f【工具返回结果】{obs}\n)# 工具观测存入记忆下一轮循环自动拼入messagesmemory.add_observation(obs)returnf达到最大循环次数{max_loop}任务未完成if__name____main__:answerrun_agent(计算 (100 20) * 5)print(最终回答:,answer)四、核心修复点总结修复致命逻辑缺陷命中FinishResponse直接return杜绝循环继续执行消息分层规范独立system全局规则多角色分离历史消息对齐大模型标准对话协议显式状态机管控AgentTaskState枚举统一管理任务流转方便后续扩展超时、中断、分支流程Pydantic结构化替代文本解析消除LLM输出不规则导致的解析崩溃问题。五、修复后预期运行效果 当前完整上下文 {role: system, content: \n你是支持工具调用的智能助手必须仅输出纯JSON禁止额外文字、Markdown、换行注释。\n可用工具\ncalculator数学计算器参数expr为数学表达式例{expr:(10020)*5}\n\n严格遵守执行规则\n1. 首次缺少数值时调用calculator获取计算结果\n2. 一旦收到system角色的工具观测结果已算出数字**禁止再次调用任何工具**必须直接输出final_answer总结答案\n3. 两种输出格式严格二选一\n- 需要调用工具时{thought:推理过程,action:工具名称,params:{expr:表达式}}\n- 已有工具计算结果、无需工具{final_answer:把计算结果整理成自然语言回答用户}\n} {role: user, content: 计算 (100 20) * 5} 【推理思考】用户需要计算数学表达式 (100 20) * 5我需要调用计算器工具来获取结果。 【工具调用】namecalculator, params{expr: (100 20) * 5} 【工具返回结果】计算结果: (100 20) * 5 600 当前完整上下文 {role: system, content: \n你是支持工具调用的智能助手必须仅输出纯JSON禁止额外文字、Markdown、换行注释。\n可用工具\ncalculator数学计算器参数expr为数学表达式例{expr:(10020)*5}\n\n严格遵守执行规则\n1. 首次缺少数值时调用calculator获取计算结果\n2. 一旦收到system角色的工具观测结果已算出数字**禁止再次调用任何工具**必须直接输出final_answer总结答案\n3. 两种输出格式严格二选一\n- 需要调用工具时{thought:推理过程,action:工具名称,params:{expr:表达式}}\n- 已有工具计算结果、无需工具{final_answer:把计算结果整理成自然语言回答用户}\n} {role: user, content: 计算 (100 20) * 5} {role: system, content: 计算结果: (100 20) * 5 600} 最终回答: 计算结果是 600仅执行一轮工具调用直接输出最终回答不会出现多轮重复调用、耗尽循环的问题。六、后续拓展自动基于Pydantic模型生成Function Call标准工具描述Schema对接模型原生工具调用接口新增文件读取工具FileReadTool实现代码读取Agent接入Chroma向量数据库实现长期记忆RAG封装AgentEngine类面向对象工程化重构拆分日志、异常、指标模块。

相关新闻