Skip to content

第 2 章:第一个问题——当工作流遇到 LLM

我遇到了什么问题

第 1 章讲完了自动化的三件套:工作流引擎 + 触发器 + 代码运行器。

这套东西能跑起来,但很快遇到了一个根本性的问题——工作流只能做"确定性"的事

举个例子:用户说"帮我总结一下今天的订单情况"。

在工作流里,这句话没法处理。因为:

  • "今天"是哪天?需要理解时间
  • "总结"是什么?是列表?是统计?是分析报告?
  • "订单情况"从哪来?数据库?API?文件?

这些判断,工作流做不了。你需要预先定义所有分支,但用户的表达方式有无数种。

怎么办?加一个 LLM 节点——让大模型来做"理解"和"判断"。

我做了什么决策

第一个决策:把 LLM 做成工作流节点

LLM 节点在工作流中的位置

最直觉的做法:在工作流里加一个 LLM 节点。

触发器 → LLM节点(理解用户意图) → 条件分支 → 执行具体操作

LLM 节点的工作很简单:接收用户输入,调用大模型 API,返回结果。

csharp
internal class LLMNodeExecutor : EnhancedNodeExecutor<LLMNodeConfig>
{
    protected override async Task<Dictionary<string, object>> ExecuteNodeAsync(
        WorkflowContextManager context,
        LLMNodeConfig config,
        NodeExecutionContext persistenceContext)
    {
        // 1. 从输入获取用户输入
        var userInput = GetUserInput(persistenceContext.NodeInput);
        
        // 2. 构建调用参数(模型ID、系统提示词、用户提示词、温度等)
        var (modelInstanceId, callOptions, tools, messages) = LLMHelper.BuildLLMServiceParams(
            config.LLMParam, config.SkillParam, userInput);
        
        // 3. 调用 LLM 服务(流式)
        var outputText = await NodeStreamHelper.ConsumeStreamAsync(
            _llmService.ChatStreamingAsync(modelInstanceId, callOptions, tools, messages),
            context, config, persistenceContext, _nodeStreamBus);
        
        // 4. 处理输出(文本 or JSON)
        return ProcessResponse(outputText, config.LLMParam.ResponseFormat);
    }
}

看起来很简单对吧?一个节点,调一下 API,拿到结果,传给下一个节点。

但跑起来之后,三个问题接连出现。

LLM 接入的三个问题

问题 1:LLM 无状态——每次对话都是"第一次见面"

工作流节点是"无状态"的——每次执行都是独立的,不记得上次执行的结果。

这对 LLM 来说是致命的。

用户说:"帮我查一下订单" LLM 回复:"好的,请问您要查哪个时间段的?" 用户说:"今天的" LLM 回复:"好的,请问您要查哪种类型的订单?"

到第三轮对话时,LLM 已经不记得第一轮说了什么。因为每次调用都是独立的——系统提示词 + 用户消息,发过去,拿结果,结束。

没有对话历史,就没有"对话"。

问题 2:用户不会写提示词

LLM 的输出质量高度依赖提示词。但大部分用户不会写提示词。

用户在工作流里配的 LLM 节点,系统提示词经常是这样的:

  • "你是一个助手"
  • "帮我处理数据"
  • "返回结果"

这种提示词,大模型只能给出泛泛的回答。

问题 3:对话轮次多了,上下文窗口撑爆

即使加了对话历史,新问题又来了——大模型有上下文窗口限制。

8K Token 的模型,大概能存 10 轮对话。超过之后,要么报错,要么前面的内容被截断。

128K Token 的模型好一些,但 Token 是要钱的,上下文越长,响应越慢,成本越高。

代码里长成了什么样

三个解法与问题的对应

解法 1:对话历史截断

最直接的解法——保存最近 10 轮对话,每次调用时带上。

csharp
public interface IAiAgentService
{
    // 发送消息(自动管理会话,首次消息自动创建会话)
    Task<ChatResponse> ChatAsync(ChatRequest request, CancellationToken cancellationToken = default);
    
    // 流式对话
    IAsyncEnumerable<ChatStreamEvent> ChatStreamAsync(ChatRequest request, CancellationToken cancellationToken = default);
    
    // 会话管理
    SessionInfo? GetSession(string sessionId);
    Task CloseSessionAsync(string sessionId);
    IReadOnlyList<SessionInfo> GetActiveSessions();
    SessionInfo CreateSession(string? userId = null);
}

注意接口里的 Session 概念——这就是为了解决"无状态"问题。每个会话有自己的 ID、自己的对话历史。用户发消息时,系统自动从会话中取出历史对话,拼成消息列表发给 LLM。

但历史不能无限增长。最简单的策略:只保留最近 N 轮

解法 2:系统提示词内置

为了解决"用户不会写提示词"的问题,我把提示词工程做成了系统内置的。

不再让用户自己写系统提示词,而是由系统根据场景自动生成:

csharp
// LLMHelper.BuildLLMServiceParams 中的消息构建逻辑
var messages = new List<LLMChatMessage>();

// 系统提示词(由系统/配置提供,不是用户手写)
if (!string.IsNullOrEmpty(llmParam.SystemPrompt))
    messages.Add(new("system", llmParam.SystemPrompt));

// 用户提示词(节点配置的输入模板,支持 {{变量}} 语法)
if (!string.IsNullOrEmpty(llmParam.Prompt))
    messages.Add(new("user", llmParam.Prompt));

// 动态用户输入(来自工作流上游或调试输入)
if (!string.IsNullOrEmpty(userInput))
    messages.Add(new("user", userInput));

关键设计:

  • SystemPrompt 由系统配置,不是用户手写
  • Prompt 支持 {变量} 模板语法,从工作流上下文中取值
  • 动态输入 来自上游节点的输出

这样用户只需要关心"这个节点要做什么",不需要关心"怎么跟大模型说话"。

解法 3:LLM 服务抽象

为了让工作流节点不直接依赖某个大模型厂商,我做了一个 LLM 服务抽象层:

csharp
public interface ILLMService
{
    // 非流式对话
    Task<LLMResponse> ChatAsync(
        string modelInstanceId,
        LLMCallOptions? callOptions = null,
        List<ToolConfig>? tools = null,
        List<LLMChatMessage>? messages = null);
    
    // 流式对话
    IAsyncEnumerable<StreamEvent> ChatStreamingAsync(
        string modelInstanceId,
        LLMCallOptions? callOptions = null,
        List<ToolConfig>? tools = null,
        List<LLMChatMessage>? messages = null);
}

注意参数设计:

  • modelInstanceId:不绑定具体模型,通过实例 ID 查找配置(可以是 OpenAI、Claude、DeepSeek、本地 Ollama)
  • tools:支持工具调用(Function Calling),这是后续 Agent 能力的基础
  • messages:标准消息列表格式

这个抽象层让工作流节点只需要说"我要调用 LLM",不需要关心"用的是哪个模型"。

业界怎么做的

同样的"LLM 接入"问题,其他框架是怎么解决的?

四大框架 LLM 处理方式对比

LangGraph 的做法是"把 LLM 当成图中的一个节点"——但它不是简单的 API 调用节点,而是一个"Agent 节点",可以自主决定下一步做什么(调用工具、继续推理、或者结束)。这比我的"LLM 节点"更进一步——它不只是"调一次 API",而是"让 LLM 自己决定要做什么"。

微软 Agent Framework (MAF) 走得更远——它用"中间件管道"来处理 LLM 的输入输出。每个中间件可以做一件事:注入上下文、压缩历史、检查安全、记录日志。这种设计让 LLM 的调用过程变得可组合、可扩展。

AgentScope Java 的解法是"ReAct 循环"——LLM 不是一次性回答,而是"思考 → 行动 → 观察 → 思考 → ..."的循环。每次"行动"可以调用工具,每次"观察"把工具结果反馈给 LLM。这让 LLM 从"回答问题"变成了"解决问题"。

Hermes 的做法是"工具自动发现"——LLM 可以调用的工具不是预先配置的,而是运行时动态注册的。新增一个工具,Hermes 自动让 LLM 知道它的存在。

关键认知

这个阶段做完,我有了第二个关键认知:

提示词工程到上下文工程的范式升级

提示词工程 → 上下文工程,这是第一个范式升级。

一开始我以为问题出在"提示词写得不好"——优化提示词就好了。

后来发现不是。问题是 LLM 是无状态的——它不知道你是谁、不知道项目长什么样、不知道上次聊了什么。

所谓的"智能",不是模型本身的能力,而是你给它的上下文的质量

你给它的上下文越精准,它的回答越好。你给它一堆无关信息,它就开始胡说八道。

这就是"上下文工程"的核心:不是让模型更聪明,而是给它更好的信息。

但"保存 10 轮对话 + 内置系统提示词"只是最粗暴的做法。真正的上下文工程要解决的是:

  • 怎么从大量信息中找到当前任务需要的那部分?(检索问题)
  • 怎么记住用户的偏好和决策?(记忆问题)
  • 怎么让上下文既全面又省 Token?(压缩问题)

这些问题,就是后面章节要展开的内容。