首页/目录/全部文章

全部文章

八个专题的源码、算法与协议笔记都在这里。

笔记列表

LlamaIndex:框架骨架与数据模型

LlamaIndex:框架骨架与数据模型

「本系列第 12/17 章」。分析基线:LlamaIndex 0.14.23。前十一章分别讲训练、GGUF 与本地推理;从本章起进入编排层。读本章时请把“对象、默认值、集成包”三件事分开,不要把源码目录里的一切都当成已经安装好的能力。

1. 先认清它是什么

LlamaIndex 不是“再包一层向量库客户端”。向量库只负责存向量和按相似度返回候选;LlamaIndex 要回答的是另一类问题:文档从哪里读入、如何切成节点、嵌入在哪一步发生、检索之后怎样过滤和重排、最后怎样把上下文交给 LLM 生成答案。这些步骤可以各自替换,所以它是编排框架,默认路径恰好是 RAG。

初学者最容易把 Index 当成黑盒问答器。正确的心智模型是:Index 管生命周期和存储拓扑,真正回答问题的是后面组合出来的查询引擎。你换成别的 Retriever、换一个后处理器、换一种合成模式,Index 里的向量不必重算。反过来,换了嵌入模型或维数,再精巧的查询组合也救不了旧索引。

理解 0.14.23 的第一件安装事实,是分清元包集成包

1
pip install llama-index

这一条只安装三样东西:llama-index-core、OpenAI 的 LLM/Embedding 集成,以及 nltk。monorepo 里大约 620 个带独立 pyproject.toml 的集成——向量库、Reader、图存储、其他厂商模型——都要另装。看到仓库目录里有 Qdrant 或 OpenAI-like,不代表当前 Python 环境已经能 import。生产项目应显式安装所需包,并锁住 core 与各集成声明的兼容区间,不要只锁一个元包版本。

多个 wheel 共同写入顶层 llama_index 命名空间(PEP 420)。应用里不要再造一个普通的 llama_index/__init__.py,否则会遮蔽插件。出现 ModuleNotFoundError 时,先查发行包名,例如 llama-index-llms-openai-like,而不是在 core 里翻厂商模块。

2. 骨架:从 Document 走到答案

标准阅读顺序可以画成一条单向链:

1
2
Reader → Document → Transform → Node → Index
→ Retriever → Postprocessors → Synthesizer → LLM

左边是“知识如何进入系统”,右边是“一次提问如何变成答案”。中间的 Index 是分界:写入阶段决定向量和文本存在哪,读取阶段不再重新解释原始文件,只对已经索引的节点提问。

索引本身不“回答问题”。BaseIndex.as_query_engine() 先取出 Retriever,再交给标准编排器:

1
RetrieverQueryEngine = retriever + postprocessors + synthesizer

三层刻意解耦。Retriever 只找候选,不生成句子;postprocessor 只按顺序过滤、扩邻或重排,不拥有查询引擎的控制流;Synthesizer 才组织 prompt 并调用 LLM。之后第 13 到 15 章会分别展开摄取、合成与 Agent。先把这条组合关系记住,调试时才能分层看:候选错了查检索,上下文错了查后处理,措辞错了查合成,而不是整链重来。

公开入口集中在 llama_index.coreDocumentVectorStoreIndexSettingsStorageContext 都从这里重导出。厂商能力走独立子包。StorageContext 只是存储实现的容器,不是又一个数据库;它把 docstore、index_store、vector store 和图存储聚在一起,方便 persist 与加载。会话记忆不属于它,那是 Memory / chat store 子系统。

同步与异步成对出现:query / aqueryretrieve / aretrieve。这不表示框架会自动把同步实现线程化。不少基类的异步方法只是直接调用同步版本,真正会不会阻塞事件循环,取决于具体集成。把这一点放在骨架里,是为了后面各章不再重复解释“为什么我写了 await 还是卡住”。

3. 数据模型:Document 包着 Node

0.14.23 的准确继承是:

1
2
3
4
5
6
7
BaseComponent
└── BaseNode
├── Node # 多模态资源容器
│ └── Document
└── TextNode # 兼容旧切分器与存储
├── ImageNode
└── IndexNode

也就是 Document(Node(BaseNode))。这是本章必须记准的类型关系。Document 既不直接继承 BaseNode,也不是 TextNode 的子类。把 Document 想成“还没切分的完整来源”,把后续 chunk 想成带 SOURCE 关系的子节点,比死记类名更有用。BaseIndex 的构造器只接受 BaseNode;直接把 Document 丢进 __init__ 会被要求改走 from_documents()

NodeMediaResource 同时持有文本、图像、音频、视频。get_content() 仍以文本为主,属于兼容接口;多模态路径才走 get_content_blocks(),按 metadata、文本、图片、音频、视频依次产出内容块。嵌入和送进 LLM 时,还会分别尊重 excluded_embed_metadata_keysexcluded_llm_metadata_keys,所以“节点里有的字段”不等于“模型看得到的字段”。

BaseNode 上真正要会用的字段不多:id_node_id 是别名)、embeddingmetadatarelationships。关系用 SOURCE / PREVIOUS / NEXT / PARENT / CHILD 指向轻量 RelatedNodeInfo,只存对方的 id、类型、metadata 和 hash,避免把整棵对象图嵌进去。切分后的 chunk 通过 SOURCE 指回原文档,ref_doc_id 由此推导。按文档删除、按文档刷新,靠的都是这条回指,而不是文件名碰巧相同。

TextNode 的类注释写明是 backward compatibility,但它仍是活跃兼容层:大量既有切分器和存储接口继续产出、接受它。新代码按 Node + MediaResource 理解模型即可,不要假设 TextNode 已经消失,也不要把旧教程里的 extra_infodoc_id 当成推荐字段——它们还在,规范名字是 metadataid_

IndexNode 是另一类“节点里嵌对象”的挂钩:它可以指向另一个 Retriever 或 QueryEngine。第 13 章检索模板会递归展开它。现在只需知道:不是所有返回节点都是普通文本块。

4. Settings 是模块级单例

1
2
3
from llama_index.core import Settings
Settings.llm = ...
Settings.embed_model = ...

Settings 不是类,也不是每次访问新建的对象。源码在模块导入时执行 Settings = _Settings(),进程内只有这一份。属性按首次读取惰性解析:仅仅 import Settings 不会创建 OpenAI 客户端;第一次读 Settings.llm 才会走 resolve_llm("default")。默认路线就是 OpenAI;缺包或缺 key 会明确报错,不会静默切到本地模型。测试环境若设置了 IS_TESTING,解析器会换成 Mock,这是进程级开关,不适合当作租户隔离手段。

构造组件时的惯例是“显式参数优先,否则回落 Settings”。局部传入的 llm= / embed_model= 只影响新对象,不会写回全局。已经造好的 Index 或 QueryEngine,通常在构造时保存了解析后的实例,之后再改 Settings.llm,不会可靠地替换引擎内部的 synthesizer。因此多租户服务里不要在请求处理中改 Settings——它没有 contextvars 隔离,也没有线程局部语义。

还有两处容易误判的缓存。第一,Settings.node_parser 与已经初始化过的 Settings.transformations 不会自动同步;需要一致时要同时赋值。第二,IngestionPipeline 未传 transformations 时,并不读取 Settings.transformations,而是自己创建切分器加 embed model。第 13 章会回到这件事。prompt_helper 也是惰性缓存:先读 helper 再换大窗口 LLM,旧 helper 不会按新模型重建。稳妥顺序是先定 LLM,再创建索引和引擎。

旧的 ServiceContextServiceContext.from_defaults()set_global_service_context() 在本版本会立即抛 ValueError。它们仍被导出,只为给旧代码一个明确迁移错误,不是“有警告但仍可用”的容器。新代码只用 Settings 或构造参数。遇到仍在教 ServiceContext 的材料,按过期文档处理。

5. 读这一章时该建立的判断

把 LlamaIndex 看成三件事的交汇:稳定的数据对象(Document / Node)、进程内默认值(Settings)、以及可替换的集成包。对象决定“什么被索引”,Settings 决定“没写参数时用谁”,集成包决定“向量和模型实际跑在哪”。三者混谈,就会出现“我已经 pip 过 llama-index,为什么还是导入失败”这类假问题。

下一章进入摄取与检索。你会看到同一份 Document 可以走两条完全不同的嵌入路径:一条在 Index 内部嵌入,一条在 Pipeline 默认链里嵌入。先分清对象和配置,才不会把两条路径叠在一起,把同一批节点算两遍向量。

LlamaIndex:摄取、索引与检索

LlamaIndex:摄取、索引与检索

「本系列第 13/17 章」。基线仍是 LlamaIndex 0.14.23。上一章给出对象与配置;本章只讲“文档如何变成可检索的节点,以及查询如何取回它们”。读完应能独立回答三句话:嵌入发生在哪一步、文本存在哪一层、一次 query 经过哪些对象。答不全,就还没有把摄取和检索从“会调用 API”提升到“能排障”。

1. 两条摄取路径,不要叠用

把文档变成带向量的节点,框架提供两条官方入口。它们都会做嵌入,职责却不同。把“切分”和“嵌入”看成两个开关,就能看清为什么不能两条路一起走。

路径 A:VectorStoreIndex.from_documents()
BaseIndex.from_documents() 先调用 docstore.set_document_hash,再跑 Settings.transformations(通常只是切分,默认是 SentenceSplitter),然后把节点交给 Index 构造。VectorStoreIndex_get_node_with_embedding() 里自己调用 embed model,按批次写入 vector store。这条路径不要求 transformation 链里包含 embedding。use_async=True 只影响初次构建时的嵌入和写入,日常异步插入应使用 ainsert / ainsert_nodes

路径 B:IngestionPipeline
Pipeline 不是 Index,而是“依次变换节点,并可选写入存储”的执行器。未传入 transformations 时,默认链是:

1
[SentenceSplitter(), Settings.embed_model]

复用 Settings.transformations。因此你在 Settings 里换过 node_parser,空构造的 Pipeline 仍可能用自己的 SentenceSplitter()。嵌入发生在 pipeline 的最后一步 transform。只有 node.embedding is not None 的结果才会被 vector_store.add();自定义链如果只放了切分器,返回的节点看起来正常,向量库却会静默不写。这是本版本里最容易浪费一下午的行为,不是报错,是少写。

两条路径都做嵌入,所以不要叠用:先让 Pipeline 嵌入,再把同一批节点丢给 VectorStoreIndex(nodes=...),Index 仍会按自己的逻辑再算一遍。选一条,走完。一次性离线建库、逻辑简单,用路径 A。需要增量、去重、对某一跳 transform 做缓存,用路径 B,并显式写出 [splitter, embed_model]

生产上常见的干净组合是:Pipeline 负责把变化过的文档写入外部 store,再用 VectorStoreIndex.from_vector_store() 挂接。后者要求 stores_text=True,会丢弃你传入的旧 storage_context,并且不会重新扫描或重新嵌入已有 collection。挂接成功只表示对象组装成功,不表示维数、度量或过滤字段已经对齐。

Pipeline 的 docstore 主要承担输入文档的 hash 去重,写入的是变换前的输入,不等于 VectorStoreIndex 用来回填检索节点的那个 docstore。把两者当成同一个“文档仓库”,后续删除和回填会对不上。

2. 去重策略与存储拓扑

Pipeline 的 DocstoreStrategy 默认是 UPSERTS。它按 ref_doc_id(否则 id_)加 hash 判断:新文档执行;同 ID 内容变了,先对 docstore 和 vector store 按 ref 删除,再执行。它处理“本次输入里消失的文档”。若要把消失视为删除,才用 UPSERTS_AND_DELETE——而且本次输入必须是完整快照。拿一小批增量去跑这个策略,未出现的历史文档会被删掉。

同一批里相同 ref_doc_id 最终只保留最后一个输入。只有 docstore、没有 vector store 时,UPSERTS 会在本次执行降级为 DUPLICATES_ONLY(对象上的策略属性本身不变),并给出 warning。store_doc_text=False 只保留 hash 与关系;后面若还想从这份 docstore 取原文,会失败。DUPLICATES_ONLY 只按全局 hash 跳过重复,不会按 ID 更新旧向量。

Index 侧的拓扑由 vector store 的 stores_text 决定。这是 0.14.23 里比“选哪个品牌的库”更先要回答的问题:

stores_text 普通文本节点是否进 docstore / IndexDict 查询结果从哪来
False store 返回 ID,再按 IndexDict 回填节点
True(且未 override) store 直接返回节点

store_nodes_override=True 时,即便 store 自己存文本,也会在本地再存一份,便于部分 ref_doc 操作。from_vector_store()stores_text=False 时直接抛 ValueError,因为挂接后没有可回填的本地映射。选外部库之前先问清楚:文本是库自己存,还是要靠本地 docstore 回填。外部库的持久化也不由 StorageContext.persist() 代管。

删除必须能按 ref_doc_id 找到一个 Document 的全部 chunk。集成如果没把这个字段写进 metadata,delete_ref_doc 就删不干净。refresh_ref_docs 会比较 docstore 里的 hash,只处理新增和内容变化项;前提是那份 hash 真的被存下来了。

3. 标准 RAG 查询链

查询不是 Index 的私有魔法,而是固定三件套:

1
2
3
4
RetrieverQueryEngine
= BaseRetriever
+ BaseNodePostprocessor[]
+ BaseSynthesizer

index.as_query_engine(similarity_top_k=10, node_postprocessors=[...], response_mode="compact") 会先 as_retriever(**kwargs),再 RetrieverQueryEngine.from_args()。同一组关键字会先后经过两层工厂。similarity_top_k 必须在创建 Retriever 时生效;事后把它传给已经构造好的 Retriever 的 from_args(),会落入未使用的 **kwargs,看起来像设了 top-k,实际检索宽度没变。

一次同步查询的顺序是:字符串变成 QueryBundleretrieve(含子类 _retrieve 与 IndexNode 递归展开)→ 按配置顺序跑每个 postprocessor → synthesize。异步对应 aretrieve / apostprocess_nodes / asynthesize。后处理器是顺序执行,因为每一步的输出是下一步的输入,不能 gather。基类 _aretrieve() 默认回退同步 _retrieve(),所以“调用了 aquery”不等于整条链非阻塞。

VectorIndexRetriever 先判断是否需要 query embedding:TEXT_SEARCHSPARSE 通常不要,其余在 store 认为这是 embedding 查询时才生成。然后组装 VectorStoreQuery(含 similarity_top_k、filters、mode、hybrid 相关字段),调用 store,必要时用 docstore 回填。source_nodes 是合成输入,不保证每段都被 LLM 完整看见,Synthesizer 还可能 truncate 或 repack。

过滤器应尽量下推到 store 的 MetadataFilters。权限尤其不要只写在 prompt 里“请忽略无权限段落”;必须在 store 预过滤或 postprocessor 里删掉。后处理顺序也有教学法上的常规:先做廉价过滤和相似度截断,再 rerank,最后做必须执行的 ACL。先截成很小的 top-N 再重排,可能把正确答案提前丢掉。

调试时把引擎拆开用:engine.retrieve() 只看检索加后处理,engine.synthesize() 只看合成。候选集合不对,先不要调 prompt。IndexNode 若指向另一个 QueryEngine,展开后的响应会变成新的 TextNode,原来的 source 不会自动并进顶层来源,引用展示时要自己处理。

4. 本章的选型建议

  • 一次性离线建库、逻辑简单:from_documents(),让 Index 内部嵌入。
  • 要增量、去重、可缓存某一跳 transform:单独用 IngestionPipeline,显式写出切分器和 embed model,默认 UPSERTS
  • 外部向量库已存文本:from_vector_store(),并在连接前保证 embedding 模型、维数和距离度量一致。
  • 需要按文档更新:保证 ref_doc_id 从切分阶段就写对,删除走 delete_ref_doc
  • 查询调试:先 retrieve 再 synthesize;权限过滤放在 store 或 postprocessor,不放在提示词。

把“嵌入发生在哪、文本存在哪、query 经过谁”写成部署记录,比抄一份通用 RAG 模板更有用。同一套对象换了存储后端,记录仍能指导你该不该回填、该不该重建。团队交接时与其给一份 Notebook,不如给这三句话和对应的 stores_text、策略枚举。

还可以用一次失败演练来巩固。假设业务更新了一份政策 PDF:路径 A 往往意味着整库重做,或自己调用 update_ref_doc / refresh_ref_docs;路径 B 则应保证 docstore 已持久化,并且本批输入不要误用 UPSERTS_AND_DELETE。假设检索能返回节点、答案却像没看见正文:先查 stores_text 是否让文本留在了你没有回填的那一侧,再查 postprocessor 是否把高分节点裁掉。假设异步接口仍然堵住事件循环:回到 Retriever 和 vector store 有没有真正的 _aretrieve / aquery,不要责怪编排器。

下一章进入合成模式、多轮对话,以及本系列推荐的本地推理接法。到那时,调用次数将按 repack 后的块数来算,而不是按你检索了几条;如果本章的候选集合已经偏了,合成章无论怎么换模式都救不回来。先把节点找对,再谈怎样把节点写成答案。

LlamaIndex:响应合成、对话与本地推理

LlamaIndex:响应合成、对话与本地推理

「本系列第 14/17 章」。基线:LlamaIndex 0.14.23。检索只给出候选;真正决定延迟、费用和保真度的,是合成模式、对话是否改写追问,以及 LLM 跑在哪一个进程里。本章把这三件事放在同一张地图上,避免“检索没问题但答案又慢又飘”。慢通常来自多次 LLM 调用,飘通常来自改写、截断或本地模板与客户端元数据不一致。

1. 合成:调用次数按 repack 后的块数算

BaseSynthesizer 接收 QueryBundleNodeWithScore[],按 MetadataMode.LLM 抽出文本(或内容块),再调用 LLM。工厂默认模式是 compact。下面三个常用模式的调用次数,都以 PromptHelper.repack 之后的 chunk 数 K 为准,不是 similarity_top_k,也不是原始检索条数。repack 可能合并小块,也可能因窗口不够再切开,所以 K 是合成器内部的工作单位。

refine 第一块走 QA prompt,之后每一块带着 existing_answer 再 refine。K 块就是 1 次 QA 加 K−1 次 refine。existing answer 变长,还可能把当前块再拆开压回队列,实际次数只多不少。流式只能看到最后一次有效 refine:前面各步必须先完整生成,后续 prompt 才有完整旧答案。它适合“希望后到的证据能修正前面结论”,代价是延迟随 K 线性上升。

compactCompactAndRefine)。 先尽量把小块拼进上下文窗口,再完全复用 Refine。K=1 时才是单次 LLM 调用;K>1 时仍是 QA + 多次 refine。不要把它写成“永远一次调用”。常规问答优先选它,是因为它通常能把 K 压小,而不是因为它取消了 refine 算法。窗口估算来自 LLM metadata 的 context_window 与预留输出长度;客户端数字和服务端真实 KV 不一致时,你会看到莫名其妙的多次 refine 或截断。

tree_summarize 每一层先 repack:只剩 1 块就出根答案;仍有多块则每块先摘要,再把摘要递归送回。同步默认按块串行,use_async=True 时同层可并行。总调用次数是各层块数之和,不是“一层算完”。中间层会压缩细节,适合全局摘要,不适合“逐证据保真”的问答。流式也只发生在收敛到根的那一次。

对照记忆,避免把名字望文生义:simple_summarize 拼接后截断、恰好一次调用,超长尾部会丢;accumulate 每块独立回答再编号拼接,不支持 streaming,运行时会抛错;compact_accumulate 先全局 repack 再 accumulate,次数更少,但仍是互不相融的多答案;generation 丢掉上下文只按问题生成,却仍保留传入的 source_nodes,那些来源不能当成依据;no_text / context_only 不调 LLM,前者答案为空、节点在 source_nodes,后者直接拼接上下文便于检查注入文本。空检索时除 generation 外直接返回空响应,不会“为了礼貌再问一次模型”。

选型可以记成课堂口诀:常规 RAG 用 compact;要覆盖全部长上下文且接受多次调用,用 refine;只要文档级摘要,用 tree_summarize;只要看模型实际吃到什么,用 context_onlysource_nodes 始终是输入候选记录,不保证每段都进入了最终 prompt。

2. 对话:改写、检索与记忆分开

index.as_chat_engine() 在 0.14.23 仍可用的模式是:

ChatMode 实际类 要点
simple SimpleChatEngine 不检索,只是带历史聊天
condense_question CondenseQuestionChatEngine 先改写,再整链 QueryEngine
context ContextChatEngine 用本轮原文检索,再带历史合成
condense_plus_context / best CondensePlusContextChatEngine 改写用于检索,合成仍用原问题

best 现在只是 condense_plus_context 的别名,不再“自动挑选 Agent”。旧文档里“best 会在 ReAct 和 OpenAI Agent 之间选择”已经过期。ChatMode.REACT / ChatMode.OPENAI 已删除,调用立即 ValueError。需要工具时,下一章的 ReActAgent + AgentWorkflow 才是正路。

as_chat_engine() 会先解析 LLM,并且无条件构造一次 as_query_enginecontextcondense_plus_context 随后还要 as_retriever。传入的关键字必须同时能被这些工厂消化,错误参数可能在模式分派前就失败。

有历史时,condense_plus_context 会先 llm.complete() 改写追问,用改写结果检索,再用原始用户句子做合成。所以一次用户消息可能对应两次 LLM 调用,加上 compact/refine 自身的 K 次,延迟要从这条加法看,不能只看生成阶段的流式输出。首轮、空历史或 skip_condense=True 才省掉改写。context 模式不改写,适合短追问仍带得动原词的场景;代词很多时,不改写会检索漂移。

记忆请用:

1
2
from llama_index.core.memory import Memory
memory = Memory.from_defaults(token_limit=6000)

ChatMemoryBuffer 仍能导入,但类注释已标记 deprecated。Memory 负责“给模型看多长的历史”,chat store 负责“消息按 key 存在哪”。ChatEngine 实例持有 Memory,不要做成全站单例;每个会话一份 Memory,或独立的 chat-store key。chat_history= 是整表替换,不是追加。reset() 清空该 Memory 对应的历史,共享 key 时会互相影响。流式对象背后还有写回历史的后台任务,生成器要消费完,过早断开可能丢掉末尾并影响历史。

system_prompt 用来约束“只根据检索资料回答”;它与某些引擎的 prefix_messages 互斥,不要两个一起塞。source_nodes 仍然只是检索结果,多轮之后尤其不能当成逐句引用。

3. 本地推理:只走 llama-server 的 OpenAI HTTP

本系列推荐的本地拓扑只有一条。LlamaIndex 不读 GGUF,也不管理推理进程;模型路径、GPU offload、槽位和 chat template 都在 llama-server 一侧。

1
2
llama-server :8080  →  OpenAILike          →  chat / 合成 / 追问改写
llama-server :8081 → OpenAILikeEmbedding → 建索引 / 查询向量

进程内的 llama_index.llms.llama_cpp.LlamaCPP 是另一条部署,不要和 HTTP 客户端混在同一套 Settings 里。混用的典型后果是:嵌入走服务、生成走进程内,上下文窗口、模板和并发模型对不上,出了错两边日志都只看到一半。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from llama_index.llms.openai_like import OpenAILike
from llama_index.embeddings.openai_like import OpenAILikeEmbedding

llm = OpenAILike(
model="Qwen2.5-7B-Instruct",
api_base="http://127.0.0.1:8080/v1",
api_key="local-placeholder",
is_chat_model=True,
is_function_calling_model=False,
context_window=8192,
)
embed = OpenAILikeEmbedding(
model_name="bge-m3",
api_base="http://127.0.0.1:8081/v1",
api_key="local-placeholder",
)

这些类来自独立集成包,需要另行安装。api_base 要带服务实际支持的 /v1。本地服务不校验密钥时,仍须给非空占位,因为 OpenAI 客户端要求有 key。is_chat_model=True 才走 chat-completions;否则消息会被拼成 completion prompt,输出会像在回显模板。Embedding 的构造参数是 model_name,不是 model。模型 ID 以 curl http://127.0.0.1:8080/v1/models 的返回为准,不要默认等于 GGUF 文件名。

is_function_calling_model=False 时,Agent 应走 ReActAgent,不要假定 llama-server 的 chat-completions 等于可靠的 OpenAI tools。context_window 只是客户端预算,必须和 server 的 -c 对齐;它不会把服务端 KV cache 变大。换 embedding 模型或维数后,必须重建索引。simple store 持久化后,加载不会向 :8081 重算文档向量,但每次查询仍要嵌入当前问题。

流式只缩短生成阶段的可见等待。embedding、向量检索、追问改写都发生在首个 token 之前。把“开了 streaming”理解成“整条 RAG 都在流”,会误判首字延迟。

把本章三件事连成一句课堂结论:先选定合成模式并按 K 估调用次数,再决定对话要不要改写追问,最后只通过 :8080 / :8081 访问本地模型。任意两件事同时改,你将无法解释延迟来自 refine、来自 condensing,还是来自 embedding 排队。

课堂练习可以很小。同一批 source_nodes,分别开 context_onlycompacttree_summarize,只比较调用次数和答案是否丢掉约束条件;不要同时改 top-k。再拿一句带代词的追问,对比 contextcondense_plus_context 实际拿去检索的字符串。本地路径上,把 is_chat_model 故意关掉一次,看输出是否变成模板回显——这能帮你记住 chat completions 与 completion prompt 不是一回事。

下一章补齐 Agent、属性图,以及持久化、多租户这类工程约束。本地模型只要还把 function calling 关掉,工具循环就按 ReAct 来读;不要指望把 ChatEngine 的模式拧到已经删除的 REACT 上会自动出现 Agent。对话引擎管“带着历史怎么检索和合成”,Agent 管“要不要调用工具”,二者不要混成一个开关。

LlamaIndex:Agent、属性图与工程化

LlamaIndex:Agent、属性图与工程化

「本系列第 15/17 章」。基线:LlamaIndex 0.14.23。上一章停在“一次查询 / 一轮对话”;本章把工具循环、属性图和上线时必须守住的边界补齐。能跑通 Notebook 不等于能上服务:Settings 的单例、存储拓扑和 Agent 的停止条件,都是工程问题。把它们留给“以后再整理”,线上的第一周就会变成互相覆盖的全局状态和停不下来的工具循环。

1. Agent:ReActAgent 配 AgentWorkflow

0.14.23 的 Agent 是 Workflow Agent,入口在 llama_index.core.agent.workflow。不要再找 AgentRunner,也不要调用已经不存在的 ReActAgent.from_tools()ChatMode.REACT / OPENAI 已删除,工具场景必须显式建 Agent。把 ChatEngine 调到一个已经不存在的模式,得到的不是回退,而是立刻抛错。

1
2
3
4
5
6
7
8
9
10
11
from llama_index.core.agent.workflow import ReActAgent, AgentWorkflow
from llama_index.core.tools import QueryEngineTool

tool = QueryEngineTool.from_defaults(
query_engine=index.as_query_engine(),
name="knowledge_base",
description="检索政策与办理步骤",
)
agent = ReActAgent(tools=[tool], llm=llm, system_prompt="先检索再回答。")
workflow = AgentWorkflow(agents=[agent])
result = await workflow.run(user_msg="退货需要什么条件?")

ReActAgent 是单步执行单元:把工具说明、历史和 reasoning scratchpad 格式化成 Thought / Action / Action Input,或 Thought / Answer,再解析模型输出。空响应或格式失败不会马上停,而是加入纠错提示进入下一轮。真正的 run()、事件路由、工具并行和停止条件在 AgentWorkflow。打印 result 看到的是 AgentOutput 的文本;result.tool_calls 才是本轮轨迹。

便捷入口 AgentWorkflow.from_tools_or_functions() 会看 llm.metadata.is_function_calling_model:为真选 FunctionAgent,否则选 ReActAgent。本系列的本地 llama-server 路径应保持 is_function_calling_model=False,因此用 ReActAgent。只有当前 server、chat template 和实测响应都正确支持 tool calls 时,才改 flag 并改用 FunctionAgentFunctionAgent.take_step() 在 flag 为假时会抛 ValueError,不是自动降级。FunctionAgent 默认允许并行工具调用;工具必须真正异步,名字叫 acall 但内部阻塞,事件循环一样会停。

Workflow 按 iteration 循环:初始化 Memory 与状态 → take_step → 无 tool call 则 finalize 结束;有多个 tool call 可并发执行,再聚合观察结果。达到 max_iterations 时,force 直接报错,generate 再让 LLM 收个尾。普通工具异常会被转成错误观察返回给模型,而不是整条工作流炸掉。位置参数 run(...) 已 deprecated,使用关键字 run(user_msg=...)。需要看过程时,用 handler.stream_events() 观察 AgentStreamToolCallToolCallResultstreaming=True 只让 LLM token 进入事件流,不会把工具本身变成流式。

多 Agent 必须有非默认的 name / description,并指定存在的 root_agent。handoff 是工作流动态加上的工具,切换当前 Agent,不会因为 return_direct 而结束整个 Workflow。RAG 作为工具时,把 QueryEngine 包成 QueryEngineTool,描述写清楚适用问题,避免模型把闲聊也送进检索。

CodeActAgent 需要你自己提供 code_execute_fn。框架不提供安全沙箱,不能对不可信代码直接 exec。它不属于本系列默认落地路径,知道边界即可。

2. 图:KnowledgeGraphIndex 换成 PropertyGraphIndex

KnowledgeGraphIndexKGTableRetrieverKnowledgeGraphRAGRetriever 均已 deprecated。新项目用 PropertyGraphIndex。旧三元组 API 只强调 (主体, 关系, 客体);属性图里的实体和关系都有稳定 ID、label、任意 properties,并回指原始 LlamaIndex Node。需要“谁在何时以何种属性关联谁”,而不是只存一条边标签,就走属性图。

默认 store 是 SimplePropertyGraphStore;默认抽取器是 SimpleLLMPathExtractorImplicitPathExtractor。前者用 LLM 抽自由路径,后者从已有 NodeRelationship 生成隐式路径。还可以换 SchemaLLMPathExtractor 做预定义约束,或 DynamicLLMPathExtractor 做动态类型。transformations 是 BaseIndex 在图抽取之前的通用切分;kg_extractors 才是插入时的图抽取链。两者顺序不要反着配。

embed_kg_nodes=True 时,原始 node 按嵌入模式取文本,KG node 按 str(kg_node) 嵌入。graph store 若不能向量查询,就落到独立 vector store。SimplePropertyGraphStore 支持基本节点、关系、路径和本地持久化,但不实现 schema、structured query、vector query,所以默认还要配向量后端。检索入口是 PGRetriever,按 store 能力组合向量召回与结构化查询。不要假设“建了属性图就自动会 Cypher”。

持久化时,StorageContext.persist() 会调用 property_graph_store.persist(...)。外部图数据库的语义由集成决定,不一定写成本地 JSON。加载后能否接着 upsert,要看该 store 是否把 ID 和来源键保存完整。

3. 工程化:存什么、隔离什么、装什么

持久化。 StorageContext 是容器,不是数据库。它聚合 docstore、index_store、vector_stores、graph_store,以及可选的 property_graph_storepersist() / load_index_from_storage() 只管这些 simple store;外部 Qdrant、图数据库的生命周期由集成自己负责。Pipeline 的 persist() 只存 cache 与 docstore,不存 transformations 或 vector store 客户端。换 embedding 维数必须重建,不能指望加载旧向量再“自动对齐”。from_vector_store() 也不验证 collection 里已经有什么。

隔离。 Settings 是进程内可变单例。服务进程应在启动时固定 llm / embed_model,按租户注入 Retriever、QueryEngine 与 Memory,而不是在请求里改全局。每个会话一份 Memory.from_defaults(...) 或独立 chat-store key。AgentWorkflow 默认也会在 context 里放一份记忆,跨用户复用同一个 workflow 实例时,先确认记忆键有没有分开。

安装面。 pip install llama-index 只有 core + OpenAI + nltk。本地路径再装 llama-index-llms-openai-likellama-index-embeddings-openai-like;外部库再装对应 llama-index-vector-stores-*。约 620 个集成是独立项目,出现 ModuleNotFoundError 时先查发行包名。不要把整个 llama-index-integrations 目录当成一个可安装包。

异步与观测。 aquery / arun / ainsert 成对存在,但不少基类 async 会回退同步实现。事件循环里的阻塞通常来自某个集成。需要查调用次数时,从 synthesizer 的 K 和 ChatEngine 是否改写追问起算,Agent 再加 tool 循环的 iteration,而不是只数 HTTP 入口次数。Callback 与 instrumentation 要在创建模型之前挂上,后改 Settings.callback_manager 不保证写进已缓存的 LLM。

拓扑复查。 第 13 章的 stores_text 在上线后仍然有效:文本在外部库时,不要再假设本地 docstore 能按 ID 回填;文本只在本地时,不要单独备份向量库却忘掉 docstore。权限过滤放在 store 或 postprocessor,不放在 Agent 的 system prompt 里指望模型自觉。

4. 收束

到这里,LlamaIndex 侧可以落地一条最小闭环:选对摄取路径、按 stores_text 放存储、用 compact 回答、用 Memory.from_defaults 做多轮、用 ReActAgent 接工具、用 PropertyGraphIndex 做新图项目。旧词作为反清单贴在旁边:ServiceContext 会抛错,ChatMode.REACT 已删除,ChatMemoryBuffer 已 deprecated,KnowledgeGraphIndex 不要再开新项目。

上线前用一张短清单自检,比再读一遍 API 名称有用。强制项:元包之外的集成是否显式安装;Settings 是否只在进程启动时赋值;每个会话是否独立 Memory;摄取是否只走一条嵌入路径;外部库的 stores_text 是否与 from_vector_store / 本地回填一致;本地 LLM 的 is_function_calling_model 是否与 Agent 类型一致。禁止项:请求里改全局 Settings、ChatMode.REACT、新项目里的 KnowledgeGraphIndex、在 Pipeline 之后再让 Index 嵌一遍。

图项目再加两条:抽取器是否写在 kg_extractors 而不是误当作普通 transformationsSimplePropertyGraphStore 是否已经配了独立向量后端。Agent 项目再加两条:max_iterations 与失败观察是否能让循环停下来;工具描述是否窄到不会把闲聊送进检索。这些不是风格问题,是 0.14.23 里已经写死的行为。

也可以把工程化理解成“让第 12 到 14 章的对象在进程里活得足够久”。Notebook 里 Settings 改来改去没有关系,因为只有你一个人跑;服务里同一份单例会被所有请求看见。Notebook 里 Memory 跟着一个 engine 实例走即可;服务里必须按会话切开,否则用户 A 的追问会污染用户 B 的改写检索。Notebook 里索引可以每次从目录重建;服务里要分清哪些状态在 persist 目录、哪些在外部库、哪些根本不该被 StorageContext 以为自己管着。

下一章把训练、GGUF、llama-server 与这条 RAG 链按命令串起来。若你只做云端 RAG,也可以把下一章的 1–5 步整段裁掉,只保留对象边界;工程清单仍然适用,因为它管的是进程与存储,不是 GGUF。本地闭环与云端闭环共享同一套编排错误,只是 LLM 的 api_base 不同。对象对了,换端点很容易;对象错了,换一家云厂商也救不回来。先修对象,然后再换供应商。

全栈串联:训练、GGUF、推理服务与 RAG

全栈串联:训练、GGUF、推理服务与 RAG

「本系列第 16/17 章」,也是最后一章。请先通读第 00–15 章:00–11 分别对应 LLaMA Factory、llama.cpp / GGML 与本地服务细节,12–15 对应 LlamaIndex 0.14.23 的骨架、摄取、合成与 Agent。本章只做串联,不发明新 API,也不把某一段的默认值说成全栈唯一标准。命令能跑,仍然要以 00–15 章核对过的对象边界为准,不要在串联时“顺便”改回旧接口。

1. 为什么要先读 00–15

全栈最常见的失败不是“哪一条命令敲错”,而是把四个系统的职责叠在一起。Factory 负责权重怎么训、怎么导出成 HuggingFace 目录;llama.cpp 负责把目录变成 GGUF、量化、用 llama-server 提供 OpenAI 兼容 HTTP;LlamaIndex 负责文档怎么切、怎么检索、怎么合成。GGML 是推理后端的张量与量化基础,应用代码通常碰不到它,但你要理解:量化发生在 C++ 工具里,不发生在 Settings.llm 里。

第 00–11 章已经用各自仓库的真实入口说明了这些边界,例如 Factory 的 llamafactory-cli train / export、llama.cpp 的 convert_hf_to_gguf.pyllama-quantizellama-server。第 12–15 章则固定了 0.14.23 里不能再靠印象拼的事实:元包只带 core、OpenAI 与 nltk;Document(Node(BaseNode))Settings 是模块单例;ServiceContext 直接抛错;两条摄取路径不要叠用;UPSERTS 是 Pipeline 默认策略;stores_text 决定拓扑;ChatMode.REACT / OPENAI 已删除;记忆用 Memory.from_defaults;图用 PropertyGraphIndex;本地只走 OpenAILike / OpenAILikeEmbedding。本章把它们按一次落地的顺序接起来。

若某一跳报错,回到对应章节核对,而不是在全栈脚本上继续打补丁。训练导出对不上,查 Factory;GGUF 架构不支持,查转换脚本;HTTP 404 或模板回显,查 server 与 is_chat_model;检索来源不对,查摄取与 stores_text;答案慢,先数合成的 K 和对话是否改写追问。

2. 一条可以照着敲的路径

目标:用 Factory 做 LoRA SFT 并导出 HuggingFace 目录,转成 GGUF、量化,用两个 llama-server 分别提供 chat 与 embedding,再让 LlamaIndex 持久化索引并查询。路径里的示例文件名来自本系列已经核对过的 Factory / llama.cpp 章节,本机目录按实际替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1) SFT(在 LLaMA Factory 仓库根目录)
llamafactory-cli train examples/train_lora/qwen3_lora_sft.yaml

# 2) 合并 LoRA,导出 HF 目录(该示例真实输出 saves/qwen3_sft_merged)
llamafactory-cli export examples/merge_lora/qwen3_lora_sft.yaml

# 3) 转 GGUF(在 llama.cpp 仓库)
python convert_hf_to_gguf.py /path/to/saves/qwen3_sft_merged \
--outfile /models/qwen3-sft-f16.gguf --outtype f16

# 4) 量化
llama-quantize /models/qwen3-sft-f16.gguf /models/qwen3-sft-q4_k_m.gguf Q4_K_M

# 5a) chat::8080
llama-server -m /models/qwen3-sft-q4_k_m.gguf --host 127.0.0.1 --port 8080 -c 8192

# 5b) embedding::8081(换本机实际的 embedding GGUF)
llama-server -m /models/bge-m3.gguf --host 127.0.0.1 --port 8081 --embedding

curl http://127.0.0.1:8080/v1/models:8081/v1/models,确认模型 ID 再写入客户端。chat 与 embedding 必须分进程:一个权重既做生成又做向量,模板、批处理和 -c 都会互相干扰。然后在 Python 里只走 HTTP,不混用进程内 LlamaCPP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from llama_index.core import (
SimpleDirectoryReader, VectorStoreIndex, StorageContext, load_index_from_storage, Settings,
)
from llama_index.llms.openai_like import OpenAILike
from llama_index.embeddings.openai_like import OpenAILikeEmbedding

Settings.llm = OpenAILike(
model="Qwen2.5-7B-Instruct", # 以 /v1/models 返回值为准
api_base="http://127.0.0.1:8080/v1",
api_key="local-placeholder",
is_chat_model=True,
is_function_calling_model=False,
context_window=8192,
)
Settings.embed_model = OpenAILikeEmbedding(
model_name="bge-m3",
api_base="http://127.0.0.1:8081/v1",
api_key="local-placeholder",
)

docs = SimpleDirectoryReader("./data", recursive=True).load_data()
index = VectorStoreIndex.from_documents(docs) # 嵌入发生在 Index 内
index.storage_context.persist("./storage/local-kb")

index = load_index_from_storage(
StorageContext.from_defaults(persist_dir="./storage/local-kb"),
embed_model=Settings.embed_model,
)
print(index.as_query_engine(similarity_top_k=5).query("退货政策是什么?"))

这是一次性建库。需要增量时改走 IngestionPipeline,默认 UPSERTS,transformations 显式写成切分器加 embed model,不要再叠一次 from_documents()。需要工具时接 ReActAgent + AgentWorkflow,不要开已经删除的 ChatMode.REACT。多轮对话用 Memory.from_defaults,不要新写 ChatMemoryBuffer

导出阶段记住 Factory 的硬约束:合并 adapter 时不要带 quantization_bit;量化模型上不能再合并 adapter。量化留给 llama-quantizeconvert_hf_to_gguf.py 读的是导出目录,不是 adapter 文件夹本身。

3. 每一跳在守什么

把命令看成契约,而不是脚本彩排:

步骤 守住的不变量
trainexport 得到可被转换脚本识别的 HF 目录;示例输出在 saves/qwen3_sft_merged
F16 GGUF → llama-quantize 推理侧的体积与精度在这一跳决定,LlamaIndex 看不见 GGUF
两个 llama-server :8080 聊天、:8081 向量;-c 与客户端 context_window 对齐
from_documents / persist 文档向量已写入 simple store,加载不再向 :8081 重算;查询仍要嵌入问题
is_function_calling_model=False 本地默认走 ReAct,而不是假想中的原生 tools

换 embedding 模型或维数,必须新建 persist 目录。stores_text 为假的外部库不能走 from_vector_store()。合成模式按第 14 章的 K 估算调用次数:compact 在 repack 后仍可能多次 refine;condense_plus_context 有历史时还多一次改写。把这些次数加在 llama-server 的并发槽位上,才能解释“为什么一个用户问题打满了 GPU”。

健康检查失败时,先看端口和 /v1 前缀,再看模型 ID,最后才怀疑 LlamaIndex。输出像 prompt 回显,优先查 is_chat_model 和 instruct 模板。上下文溢出要同时核对 server -c、客户端窗口、chunk、top-k、历史和 max_tokens。客户端把 context_window 写成更大的数字,不会扩大服务端 KV。

4. 如何选路径

不必每次都走完整链条。按你缺的那一层裁剪,这是本系列作为课本而不是清单的原因:

  • 只验证数据格式、模板和损失是否有限: Factory 的短跑 trainchat 或 Board 即可,不必转 GGUF。
  • 只验证本地吞吐、槽位和 chat template: llama-server + curl,不必上 LlamaIndex。
  • 已有云端模型和托管向量库、只要 RAG: 跳过上面 1–5 步,直接安装 core 与对应集成;仍然遵守第 12–13 章的对象和摄取边界。
  • 本机闭环、要引用私有文档: 走本章命令,查询用 compact,多轮用 Memory.from_defaults
  • 要工具、增量更新或属性图: 第 15 章的 AgentWorkflowPropertyGraphIndex,摄取用 Pipeline 的 UPSERTS,不要开已废弃的 KnowledgeGraphIndex
  • 要多租户服务: 不要在请求里改 Settings;按租户注入 engine 与 Memory。这是第 12、15 章的工程结论,与是否本地推理无关。

选型时回到具体章节核对事实,而不是凭印象拼接 0.10 时代的教程:ServiceContext 已抛错,ChatMemoryBuffer 已 deprecated,ChatMode.REACT 已删除,pip install llama-index 不会装齐约 620 个集成。版本数字变了,先重读对应章的“会抛错 / 已删除 / 默认值”,再改自己的仓库。

若你带团队分工,也可以按章节切开责任:训练同学读 00–11 的 Factory 与导出约束;推理同学读 llama-server 与量化;应用同学读 12–15,只把 :8080 / :8081 当成两个 OpenAI 兼容端点。全栈负责人的工作是守住本章这张表,防止有人在应用进程里再拉起一份进程内 LlamaCPP,或在 Pipeline 之后又 from_documents 一次。

系列结束

第 00–16 章合在一起,是一条可复查的全栈课本:训练导出、GGUF 量化、HTTP 推理、RAG 编排。从哪一章切入,取决于你缺的是权重、服务还是检索质量。选定一条路径后,用该章已经出现过的命令和对象边界做最小验证,再向外扩存储、权限与 Agent。不要同时换模型、换切分、换合成模式和换 Agent,否则你无法判断是哪一跳坏了。

复习时不必按文件名从 00 扫到 16。可以倒着问三个问题:用户问题经过了几次 LLM 调用、文档向量是在哪一次写入的、生成请求打到了哪一个端口。答得出,全栈就接上了;答不出,就回到第 14 章数 K、第 13 章看摄取、第 12 章看 Settings,或回到 00–11 查导出与 server。正向阅读建立地图,反向提问用来验收。

落地验收建议只做三件事,且一次只动一件。第一,固定提示问 llama-server 的 chat 端口,确认模板与中文指令遵循。第二,对同一批文档建索引后立刻 persist、重启进程再 load_index_from_storage,确认查询不再重算文档向量、但问题向量仍会打到 :8081。第三,把同一句带代词的追问分别交给 as_query_enginecondense_plus_context,数清 LLM 调用次数是否符合第 14 章。三件事都稳定,再打开 Agent 或外部向量库。

本系列到此结束。若只记住一句话:先固定“权重在哪推理、向量在哪存储、嵌入发生在哪一步”,再谈 Prompt 与工具。三者未定,后面的技巧都是噪音。选路时宁肯裁掉整段链条,也不要同时开两条互相嵌入的摄取路径,或同时开 HTTP 与进程内两套本地推理。读完 00–15 再改生产配置,比对着搜索引擎拼旧参数更安全。旧博客里的类名可以当索引,不能当本版本的行为说明。

Modbus 总览与工作原理

Modbus 总览与工作原理

Modbus = 应用层客户/服务器报文协议
不绑定某一种物理介质:同一 PDU 可跑在 RS-485 RTU 或以太网 TCP 上
官方规格页:https://www.modbus.org/modbus-specifications


1. Modbus 是什么

Modbus 提供设备间的远程数据访问:读线圈、读寄存器、写单个/多个点等。
通信模型是 Client 发起请求 → Server 应答(串行时代常称 Master/Slave)。

属性
起源 Modicon(现施耐德)1979
维护 Modbus Organization
应用层规范 Application Protocol V1.1b3
串行线 Serial Line V1.02(RTU / ASCII)
TCP Messaging Implementation Guide V1.0b;默认端口 502
最大 PDU 253 字节(继承串行 ADU≤256 的历史约束)
TCP ADU PDU + 7 字节 MBAP ≈ 260 字节

2. 分层:PDU 与 ADU

1
2
3
4
5
┌─────────────────────────────────────┐
│ PDU │ Function Code + Data(传输无关)
├─────────────────────────────────────┤
│ ADU │ 加上地址 / MBAP / CRC 等
└─────────────────────────────────────┘
传输 ADU 组成(概念)
RTU Address(1) + PDU + CRC(2)
ASCII : + 十六进制字符编码 + LRC + CR LF
TCP MBAP(7) + PDU(无 CRC,由 TCP 校验)

关键点:学会功能码与数据模型后,换 RTU/TCP 只是换“信封”。


3. 典型事务

1
2
3
Client                         Server
│── Request (FC + 地址/数量) ──▶│
│◀─ Response (数据或异常) ──────│

特点:

  • 同步问答:一问一答(串行总线尤其严格)
  • 无发现协议:地址/寄存器表靠手册或配置
  • 无内建安全:明文;安全需 TLS(Modbus/TCP Security)或网络隔离

4. 三种常见形态

形态 介质 校验 典型场景
Modbus RTU RS-485 / RS-232 CRC16 变频器、仪表、IO 模块
Modbus ASCII 串行 LRC 遗留/调试可读性
Modbus TCP 以太网 TCP 校验和 PLC、网关、上位机

网关常见:以太网侧 TCP Client/Server,串口侧 RTU Master,用 Unit ID 选从站。


5. 与其它协议对比(机器人语境)

Modbus CANopen MQTT EtherCAT
模型 请求/响应 对象字典 + PDO/SDO Pub/Sub 过程数据 + 邮箱
实时性 低~中(轮询) 中(PDO) 非硬实时
发现/描述 弱(靠点表) EDS/DCF Topic 约定 ESI/XML
典型用途 IO/仪表/简单驱动 运动/设备网络 云边消息 多轴伺服

工程分层示例:

1
2
3
4
伺服/关节:EtherCAT / CANopen
外围 IO、温控、电表:Modbus RTU/TCP
云/厂级:MQTT
机器人内部:ROS 2 DDS

6. 学习路径一句话

  1. 弄清四个数据表(线圈/离散/保持/输入)
  2. 背常用功能码 01/02/03/04/05/06/15/16
  3. 分清 RTU 帧与 TCP MBAP
  4. 用 libmodbus / pymodbus 实读一台设备

下一篇:01-数据模型与寻址.md

数据模型与寻址

数据模型与寻址

Modbus 把设备数据抽象成四张“表”
协议层用 0-based 地址;厂商手册常写 1-based 点号(如 40001)——这是联调第一坑

依据:Application Protocol V1.1b3 数据模型章节。


1. 四类数据

访问 宽度 典型用途
Coils(线圈) 读写 1 bit 数字输出、继电器
Discrete Inputs(离散输入) 只读 1 bit 数字输入、开关量
Holding Registers(保持寄存器) 读写 16 bit 设定值、参数、过程量
Input Registers(输入寄存器) 只读 16 bit 测量值、状态字

Client 不能“发明”第五张表;厂商扩展通常映射进保持/输入寄存器。


2. 协议地址 vs 手册点号

规范 PDU 里的地址是 从 0 开始 的偏移。
许多手册沿用历史记号:

手册常见写法 含义 PDU 起始地址
00001–0xxxx 线圈 地址 = 点号 - 1
10001–1xxxx 离散输入 地址 = 点号 - 10001
30001–3xxxx 输入寄存器 地址 = 点号 - 30001
40001–4xxxx 保持寄存器 地址 = 点号 - 40001

例:手册写 Holding 40001 → PDU 地址 0,功能码 03

有的厂商直接写“寄存器 0”“寄存器 1000”,以手册为准,不要死套 4xxxx。


3. 数量与对齐

  • 位:按 bit 打包进字节(MSB/打包顺序以实现/规范为准,读手册)
  • 寄存器:每个 16-bit;一次读写有 最大数量限制(受 PDU≤253 约束,常见 ≤125 寄存器)
  • 32-bit float / int:通常占 2 个寄存器,存在字节序/字序问题(见 05

4. 点表(Register Map)工程习惯

一份可用的点表应包含:

字段
名称 motor_speed_rpm
表类型 Holding
协议地址 100
数据类型 uint16 / float32
缩放 raw * 0.1
读写 R/W
功能码 03 / 16
备注 大端、字倒序

没有点表就开写代码 = 赌运气。


5. 广播地址(串行)

  • 串行从站地址:1–247(常用)
  • 0 = 广播(写类功能;通常无响应
  • TCP 直连设备时 Unit ID 常用 0xFF0(见 TCP 篇)

下一篇:02-功能码与PDU.md

功能码与 PDU

功能码与 PDU

Function Code(FC) 决定“对哪张表做什么”
正常响应:回显 FC;异常响应:FC | 0x80 + 异常码

依据:Modbus Application Protocol V1.1b3。


1. PDU 结构

请求 PDU

1
2
3
4
┌──────────────┬────────────────────┐
│ Function Code│ Data │
│ 1 byte │ 地址、数量、值… │
└──────────────┴────────────────────┘

正常响应 PDU:Function Code 相同 + 数据
异常响应 PDUFunction Code + 0x80 + Exception Code(1 字节)


2. 常用功能码(必记)

FC 名称 操作
01 Read Coils 线圈 读多个位
02 Read Discrete Inputs 离散输入 读多个位
03 Read Holding Registers 保持寄存器 读多个寄存器
04 Read Input Registers 输入寄存器 读多个寄存器
05 Write Single Coil 线圈 写单线圈(0xFF00/0x0000
06 Write Single Register 保持寄存器 写单寄存器
15 (0x0F) Write Multiple Coils 线圈 写多线圈
16 (0x10) Write Multiple Registers 保持寄存器 写多寄存器

其它常见:

FC 用途
07 Read Exception Status(串行)
08 Diagnostics
20/21 文件记录读写
22 Mask Write Register
23 Read/Write Multiple Registers
43/14 Read Device Identification

以设备支持列表为准;很多仪表只实现 03/06/16。


3. 读保持寄存器示例(FC 03)

请求 Data:

字段 大小 含义
Starting Address 2 B 起始地址(0-based)
Quantity 2 B 寄存器数量

响应 Data:

字段 含义
Byte Count 后续字节数 = Quantity × 2
Register Values 每寄存器 2 字节,高字节在前(大端)

4. 写多寄存器示例(FC 16)

请求含:起始地址、数量、字节数、寄存器值…
响应通常回:起始地址 + 数量(确认),不回显全部数据。


5. 异常码(常见)

名称 含义
01 Illegal Function 不支持该 FC
02 Illegal Data Address 地址/范围非法
03 Illegal Data Value 数据值非法
04 Slave Device Failure 设备故障
06 Slave Device Busy 忙,可稍后重试

排障:先看是超时无响应还是异常响应——两者原因完全不同。


6. 实现检查清单

  • FC 与表类型匹配(别对输入寄存器用 06)
  • 数量未超设备/规范上限
  • 地址用 0-based 还是手册点号已换算
  • 写线圈用 0xFF00 而非 0x0001(FC05)
  • 处理异常码,不要只当通信失败

下一篇:03-串行线RTU与ASCII.md

串行线 RTU 与 ASCII

串行线 RTU 与 ASCII

规范:Modbus over Serial Line Specification and Implementation Guide V1.02
新项目优先 RTU;ASCII 多用于遗留或人工可读调试


1. 物理层常见实践

项目 建议
介质 RS-485 半双工最常见;短距可用 RS-232
拓扑 总线 + 两端终端电阻(常 120Ω)
波特率 9600 / 19200 / 38400 / 115200… 全网一致
数据位 常 8
校验 Even / Odd / None(与设备一致)
停止位 1 或 2
从站地址 1–247,唯一

2. Modbus RTU 帧

1
2
3
4
┌─────────┬──────────────────┬────────┐
│ Address │ PDU (FC+Data) │ CRC16 │
│ 1 byte │ ≤253 bytes │ 2 bytes│
└─────────┴──────────────────┴────────┘

特点:

  • 二进制紧凑
  • CRC 低字节在前(小端存放于线上的常见描述:先发 CRC Lo)
  • 帧与帧之间靠静默间隔定界(经典要求约 3.5 字符时间;高速/实现中常有放宽与 FIFO 影响)

主站轮询:同一时刻总线通常只有一问一答;多主站需额外仲裁(标准串行线不原生支持多主)。


3. Modbus ASCII 帧

1
: + 十六进制字符编码的 (Address + PDU) + LRC + CR LF
  • 每字节变 2 个 ASCII 字符 → 更慢、更大
  • : 与 CRLF 定界,对“字符间隙”更宽容
  • 校验为 LRC

4. 时序要点(RTU)

概念 说明
字符超时 帧内字符间隔过大可能判为新帧/错误
响应超时 主站发出后等待从站应答的时间
周转延迟 485 方向切换方向需要时间

嵌入式注意:DE/RE 收发使能脚时序不对 → 丢首字节/CRC 错。


5. 与 TCP 对照

RTU TCP
地址 帧头 Address MBAP Unit ID(子网)/ IP
校验 CRC TCP 校验
定界 时间间隙 长度字段
并发 可多连接

下一篇:04-ModbusTCP与MBAP.md

Modbus TCP 与 MBAP

Modbus TCP 与 MBAP

规范:Modbus Messaging on TCP/IP Implementation Guide V1.0b
默认端口:502/tcp
PDU 与串行相同;差别在 MBAP 头 与无 CRC


1. TCP ADU

1
2
3
┌────────────────────────────────┬──────────────────┐
│ MBAP Header (7 bytes) │ PDU │
└────────────────────────────────┴──────────────────┘

MBAP 字段

字段 长度 含义
Transaction ID 2 B 事务标识,请求/响应配对
Protocol ID 2 B 恒为 0 = Modbus
Length 2 B 后续字节数 = Unit ID(1) + PDU
Unit ID 1 B 单元标识(串行子网从站号 / 直连占位)

2. Unit ID 怎么用

依据实现指南的常见建议:

场景 Unit ID
直连 TCP 设备 常用 0xFF0(以设备为准)
经网关访问后方 RTU 从站 填该从站地址 1–247
广播(经网关写) 视网关;串行侧 0 为广播

错误使用显著 Unit ID + 日后 IP 改指到网关,可能导致误路由——指南因此推荐直连用非显著值。


3. 连接模型

  • Client 主动连 Server:502
  • 可保持长连接,多事务复用(靠 Transaction ID)
  • Server 可并发多 Client(实现相关)

与 RTU 不同:TCP 下“主站”概念弱化,任何 Client 都可发起。


4. 安全

经典 Modbus TCP 无加密无认证。选项:

  • 网络隔离 / VPN / 防火墙只放行必要主机
  • Modbus/TCP Security(TLS,另有规范)
  • 应用层鉴权(非标准,设备相关)

工控生产网不要对公网裸奔 502。


5. 抓包辨认

Wireshark 滤镜:tcp.port == 502modbus
可见 Transaction ID、FC、寄存器地址与异常码。

下一篇:05-时序异常与互操作.md