第 2 章:第一个问题——当工作流遇到 LLM
我遇到了什么问题
第 1 章讲完了自动化的三件套:工作流引擎 + 触发器 + 代码运行器。
这套东西能跑起来,但很快遇到了一个根本性的问题——工作流只能做"确定性"的事。
举个例子:用户说"帮我总结一下今天的订单情况"。
在工作流里,这句话没法处理。因为:
- "今天"是哪天?需要理解时间
- "总结"是什么?是列表?是统计?是分析报告?
- "订单情况"从哪来?数据库?API?文件?
这些判断,工作流做不了。你需要预先定义所有分支,但用户的表达方式有无数种。
怎么办?加一个 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,拿到结果,传给下一个节点。
但跑起来之后,三个问题接连出现。

问题 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 接入"问题,其他框架是怎么解决的?

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?(压缩问题)
这些问题,就是后面章节要展开的内容。