LLM应用开发:消息组装与二次调用的工程实践 1. 项目概述从消息组装到二次LLM调用的关键一跃在构建基于大语言模型LLM的智能应用时我们常常会陷入一个误区认为只要把用户的问题和一堆背景资料“扔”给模型它就能自动理解上下文并给出完美答案。实际上LLM对输入信息的格式、顺序和结构极其敏感。一个混乱、冗长或结构不清的输入Prompt即使包含所有必要信息也极可能导致模型输出偏离预期、逻辑混乱甚至完全错误的结果。今天要拆解的正是OpenCode项目中解决这一核心痛点的关键步骤——User Content组装与第二次LLM调用。这不仅是代码生成或分析流程中的一个环节更是决定智能体Agent能否精准理解复杂任务、协调多工具协作的“神经中枢”。简单来说这一步要做的是将第一阶段例如工具调用、代码检索等产生的原始、碎片化的中间结果与用户的原始请求进行智能整合重新“烹饪”成一份LLM能够高效消化并精准执行的“营养餐单”。它关乎效率更关乎准确性。我见过太多项目因为忽略了Prompt的精细组装导致模型反复误解意图、工具调用错误最终让整个智能流程崩溃。OpenCode在这里的设计提供了一套非常值得借鉴的工业化思路。2. 核心思路拆解为何需要“二次加工”与“二次调用”在深入代码之前我们必须先理解这个设计背后的逻辑。为什么不能把第一阶段的原始结果直接作为最终答案或者直接拼接起来进行第二次调用2.1 第一次LLM调用的角色与产出局限通常在类似OpenCode的智能编码或分析工作流中第一次LLM调用Step 8或更早承担着“任务规划与工具调度”的角色。它的输入是用户的自然语言描述输出可能是一个结构化的指令例如“需要调用代码检索工具在项目A中查找与‘用户登录’相关的函数然后调用静态分析工具评估其安全性”。这个阶段的产出是元指令Meta-Instruction而不是最终的用户内容。它可能是JSON格式的动作序列、一组工具调用参数或者简单的下一步指示。这些产出是面向系统、面向后续流程的并不直接适合呈现给用户。它们通常缺乏对上下文的完整解释格式生硬并且可能包含内部使用的标识符或中间状态。2.2 User Content组装的必要性格式化、语境化与精炼直接将元指令和原始工具输出丢给用户或作为最终答案体验会非常糟糕。因此需要“User Content组装”这个环节来桥接。它的核心目标有三个格式化转换将内部的结构化数据如JSON、工具返回的原始日志、代码片段转换为符合人类阅读习惯的自然语言描述。例如将{“tool”: “code_search”, “result”: [“file_a.py: line 10-25”]}转换为“在file_a.py文件的第10至25行找到了相关函数实现”。语境重建将分散的、按执行顺序产生的多个中间结果重新以逻辑连贯的方式组织起来使其围绕用户的原始问题形成一个完整的叙事。比如先总结发现了什么代码再分析这些代码的特点最后给出综合结论。信息精炼与过滤工具的输出可能包含冗余信息、调试日志或错误信息。组装过程需要提取关键信息过滤噪音只保留对回答用户问题有直接价值的部分。2.3 第二次LLM调用的战略价值从“执行报告”到“智慧解答”组装好的User Content虽然可读但可能仍然是事实的罗列。第二次LLM调用的作用就是赋予这些事实以“智慧”。它接收组装后的、富含上下文的信息并执行最终的综合推理、总结归纳、建议生成或直接生成最终的用户答案。这是一个质的飞跃第一次调用决定“做什么”第二次调用负责“怎么说以及最终的结论是什么”。第二次调用可以利用第一次调用及其后续动作产生的全部上下文做出更全面、更深入的判断。例如在代码审查场景中第一次调用安排检查了代码风格和潜在bug组装后的内容列出了所有发现的问题点第二次调用则能综合这些问题判断其严重性等级给出修改优先级建议并生成一份专业的审查报告。3. 消息格式转换构建LLM能理解的“对话记忆”OpenCode处理此环节的核心在于其消息格式转换机制。LLM特别是Chat Completion API通常依赖一套固定的消息角色格式如system,user,assistant。如何将动态的工作流历史注入这个固定框架是设计的难点。3.1 工作流历史与标准化消息的映射在OpenCode的上下文中工作流引擎会记录下每一步的详细轨迹用户输入、第一次LLM的思考、工具调用请求、工具执行结果、工具输出解析等。这些轨迹是线性的、机器友好的日志。转换模块需要将这些日志项智能地映射到标准的聊天消息序列中。一个常见的策略是用户初始请求通常保留为最初的user消息。第一次LLM的规划与思考可以转换为一条assistant消息内容可能是“我将尝试通过以下步骤来解决您的问题1... 2...”以此向第二次调用的LLM展示“我之前的思考过程”。工具调用与结果这是转换的关键。不能简单罗列“调用了工具X返回了数据Y”。更好的方式是将一对“调用-结果”转换为一个连贯的叙述块并作为user或assistant消息的补充内容。例如以“我查询了代码库发现如下相关片段”开头然后附上精炼后的代码。这模拟了人类在解题过程中查阅资料并内化信息的行为。注意这里有一个重要技巧即避免让LLM认为工具输出是新的用户输入。如果错误地将所有工具结果都设为user角色可能会误导模型以为有多个用户在交替提问。通常将整个工作流历史除最初用户请求外都置于assistant的“上下文”或“内部思考”范畴内更为安全。3.2 上下文长度管理与信息压缩策略工作流可能很长尤其是涉及多次工具调用时。将所有原始日志都放入对话历史极易超出模型的上下文窗口限制。因此消息格式转换必须包含压缩与摘要策略。关键信息提取对于工具返回的大段代码或数据不是全部放入而是提取关键行、总结核心发现。例如静态分析工具可能输出50个警告但转换模块可能只选取严重性为“高危”的3-5条放入上下文。渐进式摘要在组装最终User Content时可以对之前步骤的产出进行摘要。例如“在前一步中我们通过工具A和B分别分析了模块X和Y共发现5类问题。接下来我们将聚焦于其中最关键的2类进行深入阐述...”。结构化表示对于列表型、对比型信息采用Markdown表格或编号列表的形式放入消息中这能极大提升LLM解析信息的效率和准确性。# 概念性代码示例一个简化的消息组装函数 def assemble_user_content(workflow_history): 将工作流历史组装成适合LLM二次调用的消息列表。 messages [] # 1. 系统指令固定或动态生成 system_msg { “role”: “system”, “content”: “你是一个资深编程助手。请基于我之前的工作和收集到的信息为用户提供一个完整、清晰、专业的最终答案。” } messages.append(system_msg) # 2. 用户初始问题 initial_user_query workflow_history.get(“initial_query”) messages.append({“role”: “user”, “content”: initial_user_query}) # 3. 组装工作流历史作为assistant的“思考过程” assistant_thinking “我已经执行了以下步骤来分析和处理您的问题\n” for step in workflow_history[“steps”]: if step[“type”] “llm_planning”: assistant_thinking f”- **规划**: {step[‘summary’]}\n” elif step[“type”] “tool_call”: # 对工具结果进行精炼 refined_result _refine_tool_output(step[“tool_output”]) assistant_thinking f”- **执行‘{step[‘tool_name’]}’**: {refined_result}\n” messages.append({ “role”: “assistant”, “content”: assistant_thinking }) # 4. 最终的用户指令触发最终回答 final_prompt “基于以上所有分析和获取的信息请给出最终的、直接面向用户的答案。” messages.append({“role”: “user”, “content”: final_prompt}) return messages def _refine_tool_output(raw_output): 精炼工具原始输出例如截取代码关键部分或总结发现。 # 实现具体的精炼逻辑如提取代码前10行后10行或总结JSON中的关键字段 # 此处为示例返回一个简化版本 if len(raw_output) 500: return raw_output[:250] “... [内容已截断仅显示关键部分] ...” raw_output[-250:] return raw_output3.3 实操心得保持角色一致性在实际操作中最大的坑之一是消息角色序列的混乱。一个稳定的模式Pattern至关重要。我个人的经验是采用“系统指令 - 用户问题 - 助手思考历程含历史- 用户最终提问”这个四段式结构。其中“助手思考历程”这部分内容虽然长但角色始终是assistant这明确告诉模型这些都是“你”即AI助手自己刚才做过的事情和获得的知识而不是来自外部的、需要你去质疑的新信息。这能显著提升最终回答的连贯性和自信度。4. 第二次LLM调用的工程化实现消息组装好后就进入了第二次LLM调用。这一步看似简单但参数配置和结果处理上有很多讲究。4.1 模型选择与参数调优第二次调用与第一次调用的目标不同因此参数设置也应有差异。模型选择如果第一次调用为了降低成本或追求速度使用了轻量级模型如 GPT-3.5-Turbo第二次调用强烈建议使用能力更强的模型如 GPT-4系列。因为最终答案的质量直接影响用户体验值得投入更多计算资源。第二次调用是“临门一脚”必须确保其推理和生成能力足够强。温度Temperature通常设置得比第一次调用更低。第一次调用可能需要一些探索性例如规划步骤时温度可以设为0.2-0.5。而第二次调用是生成最终确定性答案温度应设得更低如0.1-0.2以确保输出的稳定性和专业性减少随机性带来的不必要变化。最大生成长度Max Tokens需要根据组装后上下文长度和预期答案长度来估算。务必留足空间避免答案被截断。一个保险的做法是如果上下文用了N个token就将最大生成长度设置为模型上限减去N再留出一个安全余量。停止序列Stop Sequences如果希望答案以特定格式结束例如一个Markdown代码块结束符“”可以设置停止序列。但需谨慎不恰当的停止序列可能导致答案提前终止。4.2 提示词Prompt工程增强尽管消息历史已经包含了丰富上下文但在最终的用户消息即触发回答的那条消息中仍需要清晰的指令。这个指令应包含角色重申再次明确AI需要扮演的角色如“资深软件架构师”。任务明确清晰说明需要基于之前的历史做什么如“请撰写一份代码审查报告”、“请给出修改建议并附上修改后的代码”。格式要求指定回答的格式如“使用Markdown格式先总结再分点列出问题最后给出建议”。风格与禁忌规定语气专业、简洁和需要避免的内容如“不要对未验证的问题做出肯定性结论”。一个强大的最终Prompt模板可能是这样的你是一位经验丰富的全栈工程师。请基于上述所有步骤中我进行的代码检索、依赖分析和安全检查的结果向用户提供一份完整的评估报告。 报告需包含以下部分 1. **总体评估**用一两句话概括代码模块的健康度。 2. **关键发现**以表格形式列出发现的主要问题包含“问题描述”、“所在文件/行号”、“严重等级”、“初步修正建议”四列。 3. **具体代码示例与修改建议**针对每个高严重性问题展示原代码片段并给出修改后的代码。 4. **后续行动建议**给出1-3条后续开发或测试建议。 请确保报告专业、清晰、可操作直接面向提出问题的开发同事。4.3 结果解析与后处理第二次LLM调用的输出就是面向用户的最终内容。但仍需进行后处理格式校验与修复检查输出是否符合要求的格式如Markdown表格是否完整。有时LLM可能会遗漏部分格式可以编写简单的规则进行自动修复例如确保表格行数匹配。内容安全与过滤对生成的内容进行必要的安全检查过滤任何不符合规定的表述此点需严格遵守所有内容安全规定不展开。引用追溯如果最终答案中引用了之前工具发现的代码行或问题点确保这些引用是准确的必要时可以附加原始数据的链接或标识符在UI上方便用户回溯。流式输出优化如果前端支持流式输出第二次调用的结果应该以流式Streaming方式返回并优先输出最重要的结论部分如总体评估以提升用户体验。5. 常见问题与故障排查实录在实际集成和调试OpenCode或类似架构时以下几个坑我几乎每次都遇到。5.1 问题一第二次LLM调用完全无视之前的历史现象最终答案看起来像是直接回答了用户的初始问题完全没有提及工具检索或分析的结果。排查思路检查消息序列首先打印或日志记录发送给第二次LLM调用的完整消息列表。确认assistant角色的那条包含工作流历史的消息是否存在且内容是否完整、可读。检查角色分配确保工具执行结果没有被错误地放在新的user消息中这会导致模型将其视为一个独立的新问题。检查上下文长度如果历史记录太长可能被模型截断。查看API返回中的usage字段确认total_tokens是否接近或超过模型上限。如果是必须实施更激进的信息摘要策略。检查系统指令系统指令是否足够强尝试在系统指令中明确强调“你必须仔细参考我之前提供的所有分析和数据”。解决方案通常问题出在消息组装逻辑。确保工作流历史被整合进一个连贯的、以assistant口吻叙述的段落中。可以增加一个明确的引导句如“以下是我为解决您的问题所执行的操作和获得的信息”然后再列出历史。5.2 问题二最终答案冗长、重复或包含无关信息现象答案正确但质量不高反复复述历史或者加入了未请求的通用知识。排查思路分析最终Prompt最终的那条user消息指令是否足够具体模糊的指令如“请回答”会导致模型自由发挥。指令必须精确例如“请总结”、“请基于以上三点发现给出建议”。检查温度参数温度是否过高过高的温度会导致生成内容发散。检查历史信息的“干净度”组装进上下文的工具输出是否包含太多原始日志、调试信息这些噪音会被模型学习并可能复现。解决方案强化最终Prompt的约束力明确要求“答案应简洁”、“仅基于已提供信息”、“不要重复背景过程”。同时在组装User Content时对工具输出做更彻底的清洗和摘要只保留核心事实。5.3 问题三处理复杂、多步骤工作流时性能下降或超时现象当工作流步骤很多时整个组装和二次调用过程变慢甚至因上下文过长导致API调用超时。排查思路性能分析对assemble_user_content函数进行性能剖析看时间消耗在信息压缩、格式转换还是网络I/O。上下文长度监控实现一个令牌Token估算器在组装后立即估算上下文长度对过长的流程提前预警或触发自动摘要。异步与缓存第二次LLM调用是否是同步阻塞的考虑异步调用。一些固定的、可复用的摘要或转换结果是否可以缓存解决方案实施分层摘要对于超长工作流不要等到最后才摘要。可以在每个主要阶段结束后就生成一个该阶段的简短摘要并替换掉该阶段的原始详细日志。最终组装时使用这些阶段摘要而非全部细节。采用更智能的模型对于极长的上下文考虑使用支持更长上下文窗口的模型如128K或以上但这会增加成本。设置超时与回退为第二次LLM调用设置合理的超时时间。如果因上下文过长失败可以回退到一个降级方案例如只使用最近N步的历史或者直接返回一个提示“分析过程过于复杂以下是核心发现[手动提取的关键点]”。5.4 问题速查表问题现象可能原因优先检查点解决方向答案忽略历史历史未正确注入或角色错误1. 消息序列完整性2. 消息角色分配重构消息组装逻辑确保历史以assistant身份清晰呈现答案冗长发散最终指令模糊温度过高1. 最终Prompt的明确性2. Temperature参数值细化最终指令加入格式和长度限制降低TemperatureAPI调用超时上下文过长网络或模型延迟1. 估算的Token数量2. 模型类型与配置实施信息压缩/摘要使用异步调用考虑更高效模型答案格式错误模型未遵循格式指令或后处理缺失1. Prompt中的格式示例2. 后处理校验逻辑在Prompt中提供更清晰的格式范例增强后处理修复功能成本异常高使用了昂贵模型或上下文过长1. 二次调用的模型选择2. 上下文长度历史记录评估轻量级模型是否够用优化压缩策略以缩短上下文6. 进阶优化与扩展思考在基本流程跑通后可以考虑以下方向进行深度优化这往往是区分普通应用和优秀产品的关键。6.1 动态上下文窗口管理实现一个自适应的上下文管理策略。根据工作流复杂度和最终任务类型动态决定保留多少历史细节。例如对于“代码生成”任务可能需要保留详细的API文档片段对于“问题诊断”可能只需要保留错误日志的摘要。可以训练一个小的分类器或设计一套规则来决策不同步骤信息的保留粒度。6.2 基于反馈的组装策略迭代收集用户对最终答案的反馈如点赞、点踩、修改建议。利用这些反馈数据可以反向优化User Content的组装策略。例如如果用户经常对包含过多代码行的答案给出负面反馈那么自动摘要模块就应该更激进地压缩代码片段。这构成了一个闭环优化系统。6.3 多模态内容组装如果工作流中涉及图像、音频或其他非文本工具的输出例如截图识别、语音转文本组装模块需要具备多模态信息整合能力。这不仅仅是拼接文本可能需要将非文本信息转换为描述性文本或者在未来直接支持多模态LLM的输入格式。当前的解决方案通常是将非文本内容先通过专用模型转化为结构化的文本描述再嵌入到消息历史中。6.4 与长期记忆/向量数据库的结合对于超长对话或多轮交互的复杂任务不能无限增长单次调用的上下文。此时可以将关键的历史决策点、工具使用结果摘要存入向量数据库作为长期记忆。在组装User Content时不仅参考本次工作流的短期历史还可以通过检索RAG从长期记忆中获取相关的过往经验使AI助手的行为更具一致性和连贯性真正像一个拥有“项目记忆”的协作伙伴。