Repository navigation
Expand file tree
/
Copy pathtutorial.html
More file actions
1932 lines (1811 loc) · 219 KB
/
Copy pathtutorial.html
File metadata and controls
1932 lines (1811 loc) · 219 KB
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>年金业绩报酬智能 Agent · 项目精讲(从零到专家)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@600;700;900&family=Noto+Sans+SC:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--paper:#F8F6EF;--paper2:#F3EFE3;--ink:#1A2231;--ink2:#4C5568;--line:#DBD5C4;
--navy:#0F2444;--blue:#2F5FBF;--gold:#8F6B26;--gold2:#B98C38;
--serif:"Noto Serif SC","Songti SC",serif;--sans:"Noto Sans SC","PingFang SC",sans-serif;
--mono:"IBM Plex Mono",Menlo,Consolas,monospace}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font:15.5px/1.95 var(--sans)}
.page{max-width:860px;margin:0 auto;padding:0 34px 90px}
/* 封面 */
.cover{min-height:92vh;display:flex;flex-direction:column;justify-content:center;
border-bottom:3px double var(--gold);margin-bottom:40px}
.cover .kicker{font-family:var(--mono);font-size:13px;letter-spacing:.3em;color:var(--gold)}
.cover h1{font:900 44px/1.35 var(--serif);color:var(--navy);margin:18px 0 8px}
.cover .sub{font-size:17px;color:var(--ink2);max-width:36em}
.cover .meta{margin-top:34px;font-family:var(--mono);font-size:12.5px;color:var(--ink2);line-height:2.1}
.cover .goldrule{width:90px;height:4px;background:var(--gold);margin:26px 0 0}
/* 目录 */
.toc{margin:0 0 56px}
.toc h2{font:700 20px/1.4 var(--serif);color:var(--navy);letter-spacing:.2em}
.toc-row{display:flex;gap:16px;align-items:baseline;padding:9px 4px;text-decoration:none;
border-bottom:1px solid var(--line);color:var(--ink)}
.toc-row:hover{background:var(--paper2)}
.toc-no{font-family:var(--mono);font-size:12.5px;color:var(--gold);width:64px;flex:none}
.toc-t{font-size:15px}
/* 章节 */
.chapter{margin:0 0 64px;border-top:1px solid var(--line);padding-top:34px}
.chapter h2{font:900 27px/1.45 var(--serif);color:var(--navy);margin:0 0 6px}
.chno{display:block;font:600 13px/1 var(--mono);letter-spacing:.24em;color:var(--gold);margin-bottom:10px}
.chapter h3{font:700 18px/1.5 var(--serif);color:var(--navy);margin:34px 0 10px;
display:flex;align-items:center;gap:10px}
.chapter h3::after{content:"";flex:1;height:1px;background:var(--line)}
.lede{color:var(--ink2);font-size:15.5px;border-bottom:1px solid var(--line);padding-bottom:14px}
p{margin:12px 0}
code{font-family:var(--mono);font-size:.9em;background:var(--paper2);padding:1px 5px;border:1px solid var(--line)}
.term{color:var(--blue);font-weight:600}
b{color:var(--navy)}
/* 功能框 */
.analogy,.keypoint,.quiz{margin:16px 0;padding:13px 16px;font-size:14.5px;line-height:1.9}
.analogy{background:var(--paper2);border:1px solid var(--line)}
.analogy b,.keypoint b,.quiz b{display:inline-block;font-family:var(--mono);font-size:12px;
letter-spacing:.15em;margin-right:10px;padding:0 8px;color:#fff}
.analogy b{background:var(--gold2)}
.keypoint{background:var(--navy);color:#EDF1F8}
.keypoint b{background:var(--gold2);color:var(--navy)}
.quiz{border:1px dashed var(--gold);background:transparent}
.quiz b{background:var(--navy)}
/* 代码块 */
pre.code{background:var(--navy);color:#DCE6F5;padding:16px 18px;overflow-x:auto;
font:13px/1.8 var(--mono);border-top:3px solid var(--gold2);margin:14px 0 6px}
pre.code .cm{color:#8FA6C9}
pre.code .kw{color:#E8C987}
.code-note{font-size:13px;color:var(--ink2);margin:4px 0 16px}
/* 图 */
figure.diagram{margin:22px 0;text-align:center}
figure.diagram svg{max-width:100%;height:auto;background:#FFFDF7;border:1px solid var(--line)}
figcaption{font-family:var(--mono);font-size:12.5px;color:var(--ink2);margin-top:8px;letter-spacing:.05em}
/* 表格 */
table.tbl{width:100%;border-collapse:collapse;font-size:14px;margin:14px 0}
.tbl th{background:var(--paper2);color:var(--navy);text-align:left;padding:8px 10px;
border:1px solid var(--line);font-weight:700;white-space:nowrap}
.tbl td{padding:8px 10px;border:1px solid var(--line);vertical-align:top}
/* 页脚 */
.bookfoot{border-top:3px double var(--gold);padding-top:18px;margin-top:70px;
font-family:var(--mono);font-size:12.5px;color:var(--ink2);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media print{.cover{min-height:auto;padding:60px 0}.chapter{break-inside:auto}
pre.code{white-space:pre-wrap}}
</style>
</head>
<body>
<div class="page">
<div class="cover">
<div class="kicker">PROJECT MASTERCLASS · 从零到专家</div>
<h1>年金业绩报酬智能 Agent<br>项目精讲</h1>
<p class="sub">一位导师带你逐文件读懂这个「多 Agent + MCP」项目:从企业年金的业务常识,到 PDF 解析、LLM 提示词、AST 安全求值、SSE 实时流,再到一次 Code Review 抓出的十个坑。每个概念都从生活直觉讲起,每段代码都来自项目真实源码。</p>
<div class="goldrule"></div>
<div class="meta">
对应代码版本 · annuity-agent v0.2(2026-07)<br>
阅读方式 · 按章顺序精读,遇到「想一想」先自己答再看答案<br>
配套动手 · 项目部署后边读边跑,第 12 章的三层测试全部可本机复现
</div>
</div>
<nav class="toc">
<h2>目 录</h2>
<a class="toc-row" href="#ch0"><span class="toc-no">第 0 章</span><span class="toc-t">开场:我们要造一台什么机器</span></a>
<a class="toc-row" href="#ch1"><span class="toc-no">第 1 章</span><span class="toc-t">业务课:企业年金与业绩报酬</span></a>
<a class="toc-row" href="#ch2"><span class="toc-no">第 2 章</span><span class="toc-t">架构课:为什么是「多 Agent + MCP」</span></a>
<a class="toc-row" href="#ch3"><span class="toc-no">第 3 章</span><span class="toc-t">数据的旅程:一个 PDF 进来之后</span></a>
<a class="toc-row" href="#ch4"><span class="toc-no">第 4 章</span><span class="toc-t">文档解析:PDF 是怎么被「读懂」的</span></a>
<a class="toc-row" href="#ch5"><span class="toc-no">第 5 章</span><span class="toc-t">合同理解 Agent:把条款读成结构化数据</span></a>
<a class="toc-row" href="#ch6"><span class="toc-no">第 6 章</span><span class="toc-t">参数绑定 Agent:从自然语言到标准字典</span></a>
<a class="toc-row" href="#ch7"><span class="toc-no">第 7 章</span><span class="toc-t">计算 Agent 与安全公式引擎</span></a>
<a class="toc-row" href="#ch8"><span class="toc-no">第 8 章</span><span class="toc-t">编排 Agent 与 SSE 实时事件流</span></a>
<a class="toc-row" href="#ch9"><span class="toc-no">第 9 章</span><span class="toc-t">服务层:FastAPI 把一切串起来</span></a>
<a class="toc-row" href="#ch10"><span class="toc-no">第 10 章</span><span class="toc-t">前端:一个 HTML 文件如何变成实时驾驶舱</span></a>
<a class="toc-row" href="#ch11"><span class="toc-no">第 11 章</span><span class="toc-t">LLM 接入层:三条通道与提示注入防御</span></a>
<a class="toc-row" href="#ch12"><span class="toc-no">第 12 章</span><span class="toc-t">测试课:怎么证明它真的算对了</span></a>
<a class="toc-row" href="#ch13"><span class="toc-no">第 13 章</span><span class="toc-t">工程课:一次 Code Review 抓出的十个坑</span></a>
<a class="toc-row" href="#ch14"><span class="toc-no">第 14 章</span><span class="toc-t">结业课:能力边界、上线路线与下一步实验</span></a>
<a class="toc-row" href="#ch-adversarial"><span class="toc-no">附章</span><span class="toc-t">红蓝对抗:让系统在真实业务里活下来</span></a>
<a class="toc-row" href="#appendix"><span class="toc-no">附录</span><span class="toc-t">速查手册</span></a>
</nav>
<section class="chapter" id="ch0">
<h2><span class="chno">第 0 章</span>开场:我们要造一台什么机器</h2>
<p class="lede">在写第一行代码之前,先看清楚我们要造的机器:它吃进什么、吐出什么、中间凭什么让人放心。</p>
<h3>0.1 一位老会计的一月</h3>
<p>每年一月是年金业绩报酬的结算季。想象一位干了二十年的老会计:桌上摞着几十份<span class="term">企业年金</span>投资管理合同,每份十几页。他的工作节奏是固定的——翻开一份合同,找到写着「业绩报酬」的那一条(有的在第四条,有的在第三条,有的压根没有),逐字读懂它约定的计提方式;再翻开对应组合的年度业绩报表,把「时间加权收益率 6.80%」「日均资产规模 50,000 万元」这些数字抄到草稿纸上;然后按计算器,把合同里的自然语言变成一个金额;最后请同事从头再算一遍交叉复核,出报告、签字归档。</p>
<p>这条人工流水线有四个工位:<b>读条款 → 认参数 → 套公式 → 出报告</b>。它的问题不在于哪一步难,而在于每一步都依赖「人眼 + 人脑 + 计算器」:慢(一个组合要花掉小时级的时间)、容易抄错(6.80% 抄成 6.08% 就是几十万的差错)、口径不统一(不同人对「计提基数」的理解可能不同)、复核方式只有「再人工来一遍」。而业绩报酬是公司主要收入来源之一——这条流水线值得被重造。</p>
<h3>0.2 这台机器的输入与输出</h3>
<p>一句话说清我们要造的东西:<b>吃进一份投资管理合同 PDF 和一份组合业绩报表 PDF,吐出「应计提业绩报酬金额」(例如 230.00 万元),外加一份每个数字都能溯源到合同原文的智能计算报告。</b>中间的全部环节,就是把老会计的四个工位机械化:项目 README 里的原话是——「条款识别 → 参数标准化绑定(支持人工确认)→ 公式生成与安全求值 → 业绩报酬计算 → 智能报告导出」,并且整个过程通过一张实时更新的网页,像直播一样展示给业务人员看。</p>
<div class="analogy"><b>生活直觉</b>我们不是在造一位「什么都懂、张口就报数的先知」,而是在造一条「每个工位都装了摄像头的流水线」:每个工位只做一件事,做完把半成品连同工单一起传给下一位;任何一站出了问题,业务人员都能看到、都能伸手纠正。先知不可审计,流水线可以。</div>
<div class="quiz"><b>想一想</b>为什么输出不能只是一个金额数字,还非得配一份报告?</div>
<p>(答案:在金融场景里,「答案对」只值一半分,「过程可查」才及格。业绩报酬要经受托人审核确认后才能支付,审计要看每个参数的出处——所以条款原文摘录、参数置信度、公式与分步计算,一样都不能少。这也是整个系统架构的第一性原理,第 2 章会正式展开。)</p>
<h3>0.3 为什么现在能造,以及怎么证明它可靠</h3>
<p>这条流水线里最难机械化的是第一个工位「读条款」——合同措辞千变万化:「计提比例为20%」「按百分之十八计提」「的22.5%提取业绩报酬」说的是同一件事。传统软件靠穷举正则,永远追不上措辞的变化;大语言模型恰好补上了这块短板:它读得懂自然语言。但模型会犯错,而这里错一个数就是真金白银。本项目的解法是<b>双引擎</b>:每一步都让模型先做,但配一套确定性的规则引擎兜底和交叉验证,模型的答案只有通过校验才被采纳(第 2 章展开)。可靠性则不靠嘴说:项目内置 3 组测试案例(超额收益法、高水位法、无条款反例)加 18 个覆盖各种刁钻措辞的基准案例,每个都有手算期望值,机器答案与人工答案逐一对表——例如案例 A 的期望值 230.00 万元,规则引擎与 LLM 通道都必须分毫不差地算出来。这就是课题要求的「人机对比验证」。</p>
<h3>0.4 全书路线图</h3>
<p>本书共 14 章(第 0~13 章),按「先懂业务、再懂架构、然后沿着数据流逐个模块拆解、最后验证」的顺序展开。你现在读的这一卷是第 0~4 章:业务与架构打底,再走完一遍数据全链路,并拆掉第一站「文档解析」。后面各卷会依次拆解三个核心 Agent、模型接入层、编排与前后端工程,最终以 18 个基准案例的人机对比验证收官。建议你先照 README 把原型跑起来(离线 <code>mock</code> 模式无需任何账号),拖入 <code>testdata/</code> 里的测试 PDF 看一遍流水线,再回来读书——直觉先行,代码才不抽象。</p>
<figure class="diagram">
<svg viewBox="0 0 720 352" font-family="Noto Sans SC, sans-serif">
<text x="360" y="24" text-anchor="middle" font-size="15" font-weight="bold" fill="#0F2444">全书 14 章学习地图(第 0 → 13 章蛇形前进)</text>
<path d="M16,54 v6 M16,57 H700 M700,54 v6" stroke="#8F6B26" stroke-width="1.2" fill="none"/>
<text x="360" y="48" text-anchor="middle" font-size="12" fill="#8F6B26">本卷讲解范围 · 第 0~4 章</text>
<!-- 第一行 ch0-ch4 -->
<rect x="16" y="66" width="124" height="46" rx="4" fill="#0F2444"/>
<text x="78" y="84" text-anchor="middle" font-size="11" fill="#DBD5C4">第 0 章</text>
<text x="78" y="101" text-anchor="middle" font-size="12.5" fill="#F3EFE3">开场·造什么机器</text>
<rect x="156" y="66" width="124" height="46" rx="4" fill="#0F2444"/>
<text x="218" y="84" text-anchor="middle" font-size="11" fill="#DBD5C4">第 1 章</text>
<text x="218" y="101" text-anchor="middle" font-size="12.5" fill="#F3EFE3">业务课·业绩报酬</text>
<rect x="296" y="66" width="124" height="46" rx="4" fill="#0F2444"/>
<text x="358" y="84" text-anchor="middle" font-size="11" fill="#DBD5C4">第 2 章</text>
<text x="358" y="101" text-anchor="middle" font-size="12.5" fill="#F3EFE3">架构·Agent+MCP</text>
<rect x="436" y="66" width="124" height="46" rx="4" fill="#2F5FBF"/>
<text x="498" y="84" text-anchor="middle" font-size="11" fill="#DBD5C4">第 3 章</text>
<text x="498" y="101" text-anchor="middle" font-size="12.5" fill="#F3EFE3">数据的旅程</text>
<rect x="576" y="66" width="124" height="46" rx="4" fill="#2F5FBF"/>
<text x="638" y="84" text-anchor="middle" font-size="11" fill="#DBD5C4">第 4 章</text>
<text x="638" y="101" text-anchor="middle" font-size="12.5" fill="#F3EFE3">文档解析 PDF</text>
<line x1="140" y1="89" x2="150" y2="89" stroke="#1A2231" stroke-width="1.4"/><polygon points="150,85 156,89 150,93" fill="#1A2231"/>
<line x1="280" y1="89" x2="290" y2="89" stroke="#1A2231" stroke-width="1.4"/><polygon points="290,85 296,89 290,93" fill="#1A2231"/>
<line x1="420" y1="89" x2="430" y2="89" stroke="#1A2231" stroke-width="1.4"/><polygon points="430,85 436,89 430,93" fill="#1A2231"/>
<line x1="560" y1="89" x2="570" y2="89" stroke="#1A2231" stroke-width="1.4"/><polygon points="570,85 576,89 570,93" fill="#1A2231"/>
<line x1="638" y1="112" x2="638" y2="148" stroke="#1A2231" stroke-width="1.4"/><polygon points="634,148 642,148 638,154" fill="#1A2231"/>
<!-- 第二行 ch5-ch9(自右向左) -->
<rect x="576" y="154" width="124" height="46" rx="4" fill="#8F6B26"/>
<text x="638" y="172" text-anchor="middle" font-size="11" fill="#F3EFE3">第 5 章</text>
<text x="638" y="189" text-anchor="middle" font-size="12.5" fill="#F3EFE3">合同理解 Agent</text>
<rect x="436" y="154" width="124" height="46" rx="4" fill="#8F6B26"/>
<text x="498" y="172" text-anchor="middle" font-size="11" fill="#F3EFE3">第 6 章</text>
<text x="498" y="189" text-anchor="middle" font-size="12.5" fill="#F3EFE3">参数绑定 Agent</text>
<rect x="296" y="154" width="124" height="46" rx="4" fill="#8F6B26"/>
<text x="358" y="172" text-anchor="middle" font-size="11" fill="#F3EFE3">第 7 章</text>
<text x="358" y="189" text-anchor="middle" font-size="12.5" fill="#F3EFE3">计算 Agent·公式</text>
<rect x="156" y="154" width="124" height="46" rx="4" fill="#8F6B26"/>
<text x="218" y="172" text-anchor="middle" font-size="11" fill="#F3EFE3">第 8 章</text>
<text x="218" y="189" text-anchor="middle" font-size="12.5" fill="#F3EFE3">双引擎·LLM 接入</text>
<rect x="16" y="154" width="124" height="46" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="78" y="172" text-anchor="middle" font-size="11" fill="#8F6B26">第 9 章</text>
<text x="78" y="189" text-anchor="middle" font-size="12.5" fill="#1A2231">编排与 SSE 事件流</text>
<line x1="576" y1="177" x2="566" y2="177" stroke="#1A2231" stroke-width="1.4"/><polygon points="566,173 560,177 566,181" fill="#1A2231"/>
<line x1="436" y1="177" x2="426" y2="177" stroke="#1A2231" stroke-width="1.4"/><polygon points="426,173 420,177 426,181" fill="#1A2231"/>
<line x1="296" y1="177" x2="286" y2="177" stroke="#1A2231" stroke-width="1.4"/><polygon points="286,173 280,177 286,181" fill="#1A2231"/>
<line x1="156" y1="177" x2="146" y2="177" stroke="#1A2231" stroke-width="1.4"/><polygon points="146,173 140,177 146,181" fill="#1A2231"/>
<line x1="78" y1="200" x2="78" y2="236" stroke="#1A2231" stroke-width="1.4"/><polygon points="74,236 82,236 78,242" fill="#1A2231"/>
<!-- 第三行 ch10-ch13 -->
<rect x="16" y="242" width="124" height="46" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="78" y="260" text-anchor="middle" font-size="11" fill="#8F6B26">第 10 章</text>
<text x="78" y="277" text-anchor="middle" font-size="12.5" fill="#1A2231">Web 服务 FastAPI</text>
<rect x="156" y="242" width="124" height="46" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="218" y="260" text-anchor="middle" font-size="11" fill="#8F6B26">第 11 章</text>
<text x="218" y="277" text-anchor="middle" font-size="12.5" fill="#1A2231">前端单页应用</text>
<rect x="296" y="242" width="124" height="46" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="358" y="260" text-anchor="middle" font-size="11" fill="#8F6B26">第 12 章</text>
<text x="358" y="277" text-anchor="middle" font-size="12.5" fill="#1A2231">MCP 服务器复用</text>
<rect x="436" y="242" width="124" height="46" rx="4" fill="#1A2231"/>
<text x="498" y="260" text-anchor="middle" font-size="11" fill="#DBD5C4">第 13 章</text>
<text x="498" y="277" text-anchor="middle" font-size="12.5" fill="#F3EFE3">人机对比验证</text>
<line x1="140" y1="265" x2="150" y2="265" stroke="#1A2231" stroke-width="1.4"/><polygon points="150,261 156,265 150,269" fill="#1A2231"/>
<line x1="280" y1="265" x2="290" y2="265" stroke="#1A2231" stroke-width="1.4"/><polygon points="290,261 296,265 290,269" fill="#1A2231"/>
<line x1="420" y1="265" x2="430" y2="265" stroke="#1A2231" stroke-width="1.4"/><polygon points="430,261 436,265 430,269" fill="#1A2231"/>
<line x1="560" y1="265" x2="572" y2="265" stroke="#8F6B26" stroke-width="1.4"/><polygon points="572,261 578,265 572,269" fill="#8F6B26"/>
<text x="584" y="269" font-size="12" fill="#8F6B26">可运行原型+可行性报告</text>
<!-- 图例 -->
<rect x="16" y="312" width="12" height="12" fill="#0F2444"/><text x="34" y="322" font-size="12" fill="#1A2231">基础篇 0–2</text>
<rect x="120" y="312" width="12" height="12" fill="#2F5FBF"/><text x="138" y="322" font-size="12" fill="#1A2231">链路篇 3–4</text>
<rect x="226" y="312" width="12" height="12" fill="#8F6B26"/><text x="244" y="322" font-size="12" fill="#1A2231">Agent 篇 5–8</text>
<rect x="346" y="312" width="12" height="12" fill="#F3EFE3" stroke="#0F2444"/><text x="364" y="322" font-size="12" fill="#1A2231">工程篇 9–12</text>
<rect x="466" y="312" width="12" height="12" fill="#1A2231"/><text x="484" y="322" font-size="12" fill="#1A2231">验证篇 13</text>
</svg>
<figcaption>图 0-1 · 全书 14 章学习地图:本卷覆盖第 0~4 章</figcaption>
</figure>
<div class="keypoint"><b>本节要点</b>这个项目的目标不是「让 AI 替人报一个数」,而是「把人工工序拆成一条可检验、可干预、可留痕的智能流水线」——记住这句话,后面每一个设计决定都是它的推论。</div>
</section>
<section class="chapter" id="ch1">
<h2><span class="chno">第 1 章</span>业务课:企业年金与业绩报酬</h2>
<p class="lede">代码可以慢慢学,但这台机器算的是养老钱。先把业务弄懂,你才知道每一行代码在保护什么。</p>
<h3>1.1 企业年金:单位帮员工多存的一笔养老钱</h3>
<p>中国的养老保障有多根支柱:第一支柱是国家的基本养老保险,人人都有;<span class="term">企业年金</span>是第二支柱——效益好的单位在基本养老之外,由单位和员工共同再缴一笔钱,专门用于员工退休后的补充养老。这笔钱不发到个人手里,而是汇成一个基金,交给专业金融机构去投资打理,让它保值增值。因为这是千万员工的「养命钱」,监管极严:谁能决策、谁能操作、谁能碰钱,法规都做了强制分工。</p>
<div class="analogy"><b>生活直觉</b>一大家子凑了一笔养老钱。放床底下会贬值,全交给某个「炒股很厉害」的亲戚又不放心。于是全家立了规矩:请专业团队来打理,但决策、干活、管钱必须是三拨人,互相盯着。</div>
<h3>1.2 三方角色:房东、装修队、物业</h3>
<p>企业年金的管理链条上有三个法定角色,用一套装修比喻就能记住:</p>
<table class="tbl">
<thead><tr><th>角色</th><th>比喻</th><th>职责</th><th>在项目测试数据里的身影</th></tr></thead>
<tbody>
<tr><td><span class="term">受托人</span></td><td>房东</td><td>代表全体受益人总负责:选聘投资管理人、验收成果、审核账单</td><td>合同 A 的甲方「平安养老保险股份有限公司」(受托人身份签约)</td></tr>
<tr><td><span class="term">投资管理人</span></td><td>装修队</td><td>真正做投资操作的人:买卖债券、股票、基金</td><td>合同 A 的乙方「华金基金管理有限公司」(测试数据中的虚构公司)</td></tr>
<tr><td><span class="term">托管人</span></td><td>物业保管钥匙</td><td>资产放在托管银行的独立账户里,进出要经它核验,数据要经它复核</td><td>报表 A 里那句「经托管人复核」</td></tr>
</tbody>
</table>
<p>关键在制衡:<b>干活的人碰不到钱,管钱的人不做投资决策,总负责的人盯着所有人。</b>装修队再有本事,钥匙在物业手里,验收单要房东签字。我们的系统正是给「房东验收账单」这个环节造工具——算出装修队(投资管理人)今年该拿多少绩效。</p>
<h3>1.3 业绩报酬:管理费里浮动的那一半</h3>
<p>投资管理人的收入分两块。一块是<b>固定管理费</b>:测试合同 A 第三条约定「按本组合资产净值的 0.60%(年费率)」收取,旱涝保收,相当于基本工资。另一块就是<span class="term">业绩报酬</span>——本质是<b>浮动管理费</b>,干得好才有,相当于绩效奖金。怎么定义「干得好」?行业里有两种主流计提模式:</p>
<p><b>超额收益法</b>。像餐厅老板和厨师的约定:只有月营业额超过约定目标(比如 30 万),才对<b>超出的部分</b>抽成。落到年金合同里,「约定目标」叫业绩基准(比如年化 4.5%),收益率超过基准的部分才计提,没超过就一分不提。</p>
<p><b>高水位法</b>。厨师去年把店做到月营业额 30 万,老板已经为那次冲高付过提成;今年跌到 20 万再涨回 28 万,能再提吗?不能——28 万以下的每一块钱老板都付过钱了。高水位法规定:只有组合净值<b>创历史新高</b>(超过历史最高的「高水位」),才对超出部分计提。这防的就是「跌下去再涨回来、同一段收益重复提成」。测试合同 B 里还有配套的一句:「业绩报酬计提后,高水位相应调整为计提后单位净值」——每提一次成,水位线就上移一次。</p>
<h3>1.4 手账:拿真合同算一遍 230 万</h3>
<p>项目 <code>testdata/</code> 内置三组中文测试 PDF(生成器 <code>generate_testdata.py</code> 可重跑):案例 A 超额收益法、案例 B 高水位法、案例 C 无业绩报酬条款。全书都拿它们当主线。先看案例 A 合同里真实的业绩报酬条款:</p>
<pre class="code"><span class="cm"># 摘自 testdata/generate_testdata.py 中生成「合同A」的真实条款文本</span>
第四条 业绩报酬
为激励投资管理人勤勉尽责,甲乙双方约定按超额收益法计提业绩报酬:
(一)业绩基准为年化收益率4.5%;
(二)当本组合当年时间加权收益率超过业绩基准时,对超额部分计提业绩报酬,计提比例为20%;
(三)计提基数=(本组合当年时间加权收益率-业绩基准收益率)×本组合当年日均资产规模;
(四)业绩报酬按年度计提,于每年度结束后经受托人审核确认后支付;
(五)当年计提的业绩报酬总额不得超过本组合当年日均资产规模的1%;
(六)当年时间加权收益率未超过业绩基准或为负时,不计提业绩报酬。</pre>
<p class="code-note">↑ 这就是系统要「读懂」的原料。注意它全是自然语言:数字混在句子里,公式藏在「(三)」的文字描述里,上限躲在「(五)」里。</p>
<p>再看报表 A 的两个关键数字:当年时间加权收益率 <b>6.80%</b>,当年日均资产规模 <b>50,000 万元</b>(即 5 亿元)。现在像老会计一样按计算器:</p>
<table class="tbl">
<thead><tr><th>步骤</th><th>算式</th><th>结果</th></tr></thead>
<tbody>
<tr><td>① 超额收益率</td><td>6.80% − 4.50%</td><td>2.30%</td></tr>
<tr><td>② 计提基数(超额收益额)</td><td>2.30% × 50,000 万元</td><td>1,150.00 万元</td></tr>
<tr><td>③ 计提金额</td><td>1,150.00 万元 × 20%</td><td>230.00 万元</td></tr>
<tr><td>④ 上限校验</td><td>50,000 万元 × 1% = 500.00 万元</td><td>230.00 < 500.00,不触发封顶</td></tr>
<tr><td><b>应计提业绩报酬</b></td><td>min(230.00, 500.00)</td><td><b>230.00 万元</b></td></tr>
</tbody>
</table>
<p>第(五)款的上限条款平时看着多余,其实是保护「房东」的保险丝:极端牛市里收益率若冲到 15%,没有上限的话业绩报酬会吃掉本金可观的一块;1% 封顶保证费用失控不了。案例 B 的高水位法同理可手算:(1.1850 − 1.1200) × 30,000 万份 × 15% = <b>292.50 万元</b>。案例 C 的合同第三条明确「除本条约定的投资管理费外,甲方无需向乙方支付其他管理报酬」——正确答案是「不计提」,它专门用来考机器会不会「无中生有」。</p>
<div class="quiz"><b>想一想</b>如果案例 A 当年收益率是 4.2%,业绩报酬是多少?</div>
<p>(答案:0。第(六)款写得明白:未超过基准「不计提」,而不是「倒扣」。用数学语言说,超额部分要套一层 max(收益率 − 基准, 0)——这个 max 你会在第 7 章计算 Agent 的标准公式里再次遇到,它就是这一款合同条文的代码化身。)</p>
<figure class="diagram">
<svg viewBox="0 0 720 310" font-family="Noto Sans SC, sans-serif">
<line x1="360" y1="16" x2="360" y2="290" stroke="#DBD5C4" stroke-width="1" stroke-dasharray="4,4"/>
<!-- 左:超额收益法 -->
<text x="185" y="30" text-anchor="middle" font-size="13.5" font-weight="bold" fill="#0F2444">超额收益法 · 超过基准才提成</text>
<line x1="55" y1="255" x2="340" y2="255" stroke="#1A2231" stroke-width="1.2"/>
<line x1="55" y1="255" x2="55" y2="60" stroke="#1A2231" stroke-width="1.2"/>
<rect x="120" y="147" width="80" height="108" fill="#2F5FBF"/>
<rect x="120" y="92" width="80" height="55" fill="#8F6B26"/>
<line x1="55" y1="147" x2="340" y2="147" stroke="#1A2231" stroke-width="1" stroke-dasharray="5,4"/>
<text x="250" y="163" font-size="12" fill="#1A2231">业绩基准 4.5%</text>
<text x="160" y="82" text-anchor="middle" font-size="12" fill="#0F2444">组合收益率 6.8%</text>
<text x="210" y="112" font-size="12" fill="#8F6B26">超额 2.3%×20%</text>
<text x="210" y="128" font-size="12" fill="#8F6B26">→ 230.00 万元</text>
<text x="48" y="259" text-anchor="end" font-size="11" fill="#1A2231">0</text>
<text x="48" y="151" text-anchor="end" font-size="11" fill="#1A2231">4.5%</text>
<text x="48" y="96" text-anchor="end" font-size="11" fill="#1A2231">6.8%</text>
<text x="160" y="273" text-anchor="middle" font-size="12" fill="#1A2231">案例 A · 日均资产 5 亿元</text>
<text x="185" y="293" text-anchor="middle" font-size="11.5" fill="#8F6B26">没超过 4.5% 就一分不提:max(超额, 0)</text>
<!-- 右:高水位法 -->
<text x="540" y="30" text-anchor="middle" font-size="13.5" font-weight="bold" fill="#0F2444">高水位法 · 创历史新高才提成</text>
<line x1="390" y1="255" x2="700" y2="255" stroke="#1A2231" stroke-width="1.2"/>
<line x1="390" y1="255" x2="390" y2="60" stroke="#1A2231" stroke-width="1.2"/>
<polygon points="614,118 665,88 665,118" fill="#8F6B26"/>
<polyline points="395,170 450,118 505,195 560,150 665,88" fill="none" stroke="#2F5FBF" stroke-width="2.5"/>
<line x1="390" y1="118" x2="700" y2="118" stroke="#8F6B26" stroke-width="1.2" stroke-dasharray="5,4"/>
<text x="398" y="110" font-size="11.5" fill="#8F6B26">高水位 1.1200(上次计提后的高点)</text>
<text x="668" y="82" font-size="11.5" fill="#0F2444">1.1850</text>
<text x="420" y="56" font-size="11" fill="#1A2231">计提额 = (1.1850−1.1200)×30,000万份×15%</text>
<text x="420" y="72" font-size="11.5" font-weight="bold" fill="#8F6B26">= 292.50 万元(案例 B)</text>
<line x1="505" y1="205" x2="505" y2="198" stroke="#1A2231" stroke-width="1"/>
<text x="505" y="220" text-anchor="middle" font-size="11.5" fill="#1A2231">跌下去再涨、只要没过前高</text>
<text x="505" y="235" text-anchor="middle" font-size="11.5" fill="#1A2231">一分不提(防重复提成)</text>
<text x="690" y="273" text-anchor="end" font-size="11" fill="#1A2231">时间 →</text>
</svg>
<figcaption>图 1-1 · 两种计提模式:超额收益法只提「基准以上」,高水位法只提「历史新高以上」</figcaption>
</figure>
<div class="keypoint"><b>本节要点</b>业绩报酬的每个要素——模式、基准、比例、上限、频率——都以自然语言写在合同里。把它们可靠地「读出来、对上号、算出来」,就是这个项目的全部内容。</div>
</section>
<section class="chapter" id="ch2">
<h2><span class="chno">第 2 章</span>架构课:为什么是「多 Agent + MCP」</h2>
<p class="lede">为什么不直接把两份 PDF 丢给一个大模型,问一句「该提多少钱」?这一章回答这个「为什么不」——它决定了整个系统的形状。</p>
<h3>2.1 「一问一答」的三宗罪</h3>
<p>把合同和报表整个塞进提示词、让 <span class="term">LLM</span>(大语言模型)直接吐一个金额,演示会很惊艳,但在金融机构落不了地,罪状有三:</p>
<p><b>其一,不可审计。</b>审计师问「2.3% 这个数从哪来」,回答「模型说的」等于零分。金额必须能溯源到合同某条原文、报表某个指标。<b>其二,幻觉。</b>金额是连续值,模型心算错一位小数就是几十万的差错,而且它答错时和答对时一样自信——你无法从语气分辨。<b>其三,无法人工干预。</b>一问一答是原子操作:置信度低的参数没有「暂停、待业务员确认、再继续」的环节;人工改一个参数,只能整个重问,答案还未必稳定。</p>
<div class="analogy"><b>生活直觉</b>这就像不能让一位口才极好的实习生独自签发付款单。他可能真答对了,但财务流程的要求从来不是「答对」,而是「每一步有单据、可复核、可追责」。</div>
<h3>2.2 分工:翻译员、登记员、精算师、行政总厨</h3>
<p>本项目把流程拆给四个 <span class="term">Agent</span>,每个只干一件能被检验的事:</p>
<p><b>合同理解 Agent</b>(<code>backend/agents/contract_agent.py</code>)是<b>翻译员</b>:把合同的自然语言条款翻译成结构化的「参数描述」——名称、数值、单位、原文依据。<b>参数绑定 Agent</b>(<code>binding_agent.py</code>)是<b>登记员</b>:合同里叫「计提比例」「提取比例」还是「业绩提成比例」都无所谓,登记员把各种俗名对到系统标准编码(如 <code>PERF_FEE_RATE</code>)上,并给每条映射打置信度,低于 0.85 的挂牌「请人工确认」。<b>计算 Agent</b>(<code>calc_agent.py</code>)是<b>精算师</b>:根据确认后的参数生成可执行公式并算出金额。<b>编排 Agent</b>(<code>backend/orchestrator.py</code>)是<b>行政总厨</b>:自己不炒菜,按菜单顺序叫号、传递半成品、盯进度,并把每个灶台的动静实时报给前厅(第 3 章细讲)。</p>
<h3>2.3 MCP:给每个工具装上国标插座</h3>
<p><span class="term">MCP</span>(Model Context Protocol)是给「模型可调用的工具」定标准接口的协议。比喻:家里的电器五花八门,但插头全是国标的——于是任何插座都能给任何电器供电,谁生产的都行。MCP 工具也一样:每个工具用统一结构自我描述——<code>name</code>(叫什么)、<code>description</code>(干什么)、<code>inputSchema</code>(吃什么)、<code>outputSchema</code>(吐什么)——任何 MCP 客户端都能发现并调用它。本项目把流程拆成 4 个 MCP 工具,Schema 集中定义在 <code>backend/mcp_tools/schemas.py</code>。看第一个:</p>
<pre class="code"><span class="cm"># backend/mcp_tools/schemas.py(节选:4 个工具中的第 1 个)</span>
MCP_TOOLS = [
{
"name": "parse_document",
"description": "解析上传的 PDF 文档,抽取全文文本并分类为合同(contract)或业绩报表(report)。",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "PDF 文件路径"},
},
"required": ["path"],
},
"outputSchema": {
"type": "object",
"properties": {
"doc_type": {"type": "string", "enum": ["contract", "report"]},
"page_count": {"type": "integer"},
"text": {"type": "string"},
},
},
},
<span class="cm"># …… extract_clauses / bind_parameters / generate_and_calc 同构定义 ……</span>
]</pre>
<p class="code-note">↑ <code>inputSchema</code> 和 <code>outputSchema</code> 都是 <span class="term">JSON Schema</span>:一种用 JSON 描述「JSON 数据长什么样」的规范。<code>required: ["path"]</code> 说「不给路径就别调我」;<code>enum: ["contract", "report"]</code> 说「我只会吐这两种类型,第三种不存在」。</p>
<p>四个工具的输入输出串起来看,你会发现一条传送带:</p>
<table class="tbl">
<thead><tr><th>MCP 工具</th><th>输入(inputSchema 核心)</th><th>输出(outputSchema 核心)</th><th>实现文件</th></tr></thead>
<tbody>
<tr><td><code>parse_document</code></td><td>path(PDF 路径)</td><td>doc_type、page_count、text</td><td>pdf_parser.py</td></tr>
<tr><td><code>extract_clauses</code></td><td>doc_type、text</td><td>scheme_type、clauses[]、params[](name_nl/value/unit/evidence)</td><td>agents/contract_agent.py</td></tr>
<tr><td><code>bind_parameters</code></td><td>extracted_params[]、scheme_type</td><td>bindings[](std_code/confidence/needs_confirmation)、missing[]</td><td>agents/binding_agent.py</td></tr>
<tr><td><code>generate_and_calc</code></td><td>scheme_type、bindings[]</td><td>formula、formula_readable、steps[]、fee_amount</td><td>agents/calc_agent.py</td></tr>
</tbody>
</table>
<p>上一个工具的输出恰好是下一个工具输入的形状:<code>extract_clauses</code> 吐出的 <code>params</code> 数组正是 <code>bind_parameters</code> 吃的 <code>extracted_params</code>;<code>bindings</code> 又直通 <code>generate_and_calc</code>。Schema 先定,好处立现:几个人可以并行开发各自的工具,用假数据互相联调;而且这套工具不绑死在本项目里——<code>backend/mcp_tools/server.py</code> 把它们注册成标准 MCP stdio 服务器,README 里一行命令 <code>claude mcp add annuity -- python3 -m backend.mcp_tools.server</code> 就能挂到任何 MCP 客户端上复用,这正是「国标插座」的含义。</p>
<div class="quiz"><b>想一想</b>outputSchema 里 doc_type 为什么用 enum 而不是自由字符串?</div>
<p>(答案:下游要按 doc_type 分支——合同走条款抽取、报表走指标抽取。枚举把「下游只认这两种」写进了接口契约,实现或模型若吐出第三种值,校验层立刻能拦住,而不是等到下游莫名其妙才发现。)</p>
<h3>2.4 双引擎:LLM 优先,规则兜底</h3>
<p>每个 Agent 内部都是「双引擎」结构。看合同理解 Agent 的入口,真实代码只有十几行:</p>
<pre class="code"><span class="kw">def</span> run(doc_type: str, text: str) -> dict:
<span class="cm">"""MCP 工具 extract_clauses 入口。"""</span>
provider = get_provider()
result = None
<span class="kw">if</span> provider.name != "mock":
tpl = _CONTRACT_PROMPT <span class="kw">if</span> doc_type == "contract" <span class="kw">else</span> _REPORT_PROMPT
prompt = tpl.replace("{{TEXT}}", text[:12000])
result = provider.complete_json(_SYSTEM, prompt)
result = _validate(result)
<span class="kw">if</span> result <span class="kw">is</span> None:
result = (_rules_contract(text) <span class="kw">if</span> doc_type == "contract"
<span class="kw">else</span> _rules_report(text))
result["engine"] = "rules"
<span class="kw">else</span>:
result["engine"] = "llm"
<span class="kw">return</span> result</pre>
<p class="code-note">↑ 来自 backend/agents/contract_agent.py。先问模型(Opus 通道),模型的 JSON 要过 <code>_validate</code> 结构校验;模型不可用、超时、输出不合格——任何一种失败都让 <code>result</code> 落回 <code>None</code>,于是转入内置正则规则引擎 <code>_rules_contract</code>。注意最后的 <code>engine</code> 字段:结果里永远记着「这是谁算的」,供审计与人机对比。</p>
<p>计算 Agent 更进一步:LLM 生成的公式<b>必须和内置标准公式交叉验证</b>才被采纳:</p>
<pre class="code"><span class="cm"># backend/agents/calc_agent.py(节选:交叉验证)</span>
<span class="kw">if</span> used_variables(llm_f) <= set(variables):
llm_fee = safe_eval(llm_f, variables)
<span class="kw">if</span> abs(llm_fee - can_fee) < 0.01:
formula = llm_f
<span class="cm"># ……</span>
note = "模型生成公式已通过标准公式交叉验证"
<span class="kw">else</span>:
note = (f"模型公式结果({llm_fee:,.2f}万元)与标准公式"
f"({can_fee:,.2f}万元)不一致,已采用标准公式")</pre>
<p class="code-note">↑ 偏差超过 0.01 万元(一百元)就弃用模型公式、采用标准公式并写明原因。金额的最终确定权永远在确定性代码手里,LLM 只有「答对了才被采纳」的份。</p>
<div class="analogy"><b>生活直觉</b>像民航驾驶舱:自动驾驶(LLM)平时开飞机又快又稳,但每个读数旁边都有一块机械仪表(规则引擎)在比对,两者不一致时,以机械仪表为准。乘客(金融业务)要的不是聪明,是确定性。</div>
<p>这套双引擎对金融场景为什么是必需而非锦上添花:金额必须<b>确定可复现</b>(同样输入永远同样输出);系统要<b>离线可演示</b>(<code>ANNUITY_PROVIDER=mock</code> 时规则引擎独立跑通全流程);模型故障时业务要<b>优雅降级</b>而不是停摆。</p>
<figure class="diagram">
<svg viewBox="0 0 720 408" font-family="Noto Sans SC, sans-serif">
<rect x="190" y="14" width="340" height="44" rx="4" fill="#0F2444"/>
<text x="360" y="32" text-anchor="middle" font-size="13" font-weight="bold" fill="#F3EFE3">浏览器 · frontend/index.html</text>
<text x="360" y="48" text-anchor="middle" font-size="10.5" fill="#DBD5C4">拖入 PDF · SSE 实时展示 · 人工确认 · 导出报告</text>
<line x1="320" y1="58" x2="320" y2="86" stroke="#1A2231" stroke-width="1.4"/><polygon points="316,86 324,86 320,92" fill="#1A2231"/>
<text x="308" y="78" text-anchor="end" font-size="11" fill="#1A2231">上传 / 确认重算 / 导出</text>
<line x1="400" y1="92" x2="400" y2="64" stroke="#8F6B26" stroke-width="1.4"/><polygon points="396,64 404,64 400,58" fill="#8F6B26"/>
<text x="412" y="78" font-size="11" fill="#8F6B26">SSE 事件流(实时)</text>
<rect x="190" y="92" width="340" height="40" rx="4" fill="#2F5FBF"/>
<text x="360" y="109" text-anchor="middle" font-size="13" font-weight="bold" fill="#F3EFE3">FastAPI · backend/main.py</text>
<text x="360" y="124" text-anchor="middle" font-size="10.5" fill="#F3EFE3">/api/jobs · /events · /recalc · /report</text>
<line x1="360" y1="132" x2="360" y2="154" stroke="#1A2231" stroke-width="1.4"/><polygon points="356,154 364,154 360,160" fill="#1A2231"/>
<rect x="190" y="160" width="340" height="44" rx="4" fill="#8F6B26"/>
<text x="360" y="178" text-anchor="middle" font-size="13" font-weight="bold" fill="#F3EFE3">编排 Agent · backend/orchestrator.py</text>
<text x="360" y="194" text-anchor="middle" font-size="10.5" fill="#F3EFE3">任务线程 _run_pipeline · 事件广播</text>
<rect x="560" y="160" width="148" height="44" rx="4" fill="#F3EFE3" stroke="#8F6B26"/>
<text x="634" y="178" text-anchor="middle" font-size="12.5" fill="#0F2444">报告生成</text>
<text x="634" y="194" text-anchor="middle" font-size="10.5" fill="#8F6B26">backend/report.py</text>
<line x1="530" y1="182" x2="554" y2="182" stroke="#1A2231" stroke-width="1.4"/><polygon points="554,178 560,182 554,186" fill="#1A2231"/>
<rect x="12" y="230" width="696" height="88" rx="4" fill="none" stroke="#2F5FBF" stroke-width="1.2" stroke-dasharray="6,5"/>
<text x="24" y="248" font-size="11.5" fill="#2F5FBF">MCP 工具集 · Schema:mcp_tools/schemas.py · 独立复用:mcp_tools/server.py</text>
<line x1="250" y1="204" x2="112" y2="250" stroke="#1A2231" stroke-width="1.2"/><polygon points="100,250 108,252 104,258" fill="#1A2231"/>
<line x1="330" y1="204" x2="280" y2="250" stroke="#1A2231" stroke-width="1.2"/><polygon points="274,250 282,252 278,258" fill="#1A2231"/>
<line x1="390" y1="204" x2="450" y2="250" stroke="#1A2231" stroke-width="1.2"/><polygon points="448,250 456,250 452,258" fill="#1A2231"/>
<line x1="470" y1="204" x2="620" y2="250" stroke="#1A2231" stroke-width="1.2"/><polygon points="622,250 630,252 626,258" fill="#1A2231"/>
<rect x="24" y="258" width="160" height="48" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="104" y="278" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#0F2444">parse_document</text>
<text x="104" y="294" text-anchor="middle" font-size="10" fill="#8F6B26">pdf_parser.py</text>
<rect x="198" y="258" width="160" height="48" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="278" y="278" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#0F2444">extract_clauses</text>
<text x="278" y="294" text-anchor="middle" font-size="10" fill="#8F6B26">agents/contract_agent.py</text>
<rect x="372" y="258" width="160" height="48" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="452" y="278" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#0F2444">bind_parameters</text>
<text x="452" y="294" text-anchor="middle" font-size="10" fill="#8F6B26">agents/binding_agent.py</text>
<rect x="546" y="258" width="160" height="48" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="626" y="278" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#0F2444">generate_and_calc</text>
<text x="626" y="294" text-anchor="middle" font-size="10" fill="#8F6B26">agents/calc_agent.py</text>
<text x="104" y="334" text-anchor="middle" font-size="10.5" fill="#1A2231">纯本地解析,不调模型</text>
<line x1="278" y1="306" x2="278" y2="340" stroke="#1A2231" stroke-width="1.2"/><polygon points="274,340 282,340 278,346" fill="#1A2231"/>
<line x1="452" y1="306" x2="452" y2="340" stroke="#1A2231" stroke-width="1.2"/><polygon points="448,340 456,340 452,346" fill="#1A2231"/>
<line x1="626" y1="306" x2="626" y2="340" stroke="#1A2231" stroke-width="1.2"/><polygon points="622,340 630,340 626,346" fill="#1A2231"/>
<rect x="198" y="346" width="444" height="48" rx="4" fill="#0F2444"/>
<text x="420" y="366" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#F3EFE3">模型接入层 · backend/llm.py(LLM 优先)</text>
<text x="420" y="382" text-anchor="middle" font-size="10.5" fill="#DBD5C4">claude_cli 订阅通道 / anthropic API / mock 离线</text>
<rect x="20" y="346" width="150" height="48" rx="4" fill="#F3EFE3" stroke="#8F6B26"/>
<text x="95" y="366" text-anchor="middle" font-size="12" font-weight="bold" fill="#8F6B26">规则引擎兜底</text>
<text x="95" y="382" text-anchor="middle" font-size="10" fill="#1A2231">失败 / 离线自动降级</text>
<line x1="198" y1="370" x2="178" y2="370" stroke="#8F6B26" stroke-width="1.4"/><polygon points="178,366 170,370 178,374" fill="#8F6B26"/>
</svg>
<figcaption>图 2-1 · 整体架构:前端 — FastAPI — 编排 Agent — 4 个 MCP 工具 — 双引擎(LLM 通道 + 规则兜底)</figcaption>
</figure>
<div class="keypoint"><b>本节要点</b>多 Agent + MCP 的本质不是赶时髦,而是把「不可靠的聪明」关进「可靠的流程」:接口用 Schema 立约,金额由确定性代码把关,LLM 的产出只有通过校验才被采纳。</div>
</section>
<section class="chapter" id="ch3">
<h2><span class="chno">第 3 章</span>数据的旅程:一个 PDF 进来之后</h2>
<p class="lede">把案例 A 的两份 PDF 拖进页面,然后跟着它们走完全程:每一站发生什么、产生什么中间数据、你在浏览器里看到什么。</p>
<h3>3.1 第一站:上传,与一张「运单号」</h3>
<p>你把「合同A」「报表A」拖进页面,前端把它们装进 <code>FormData</code>,<code>POST /api/jobs</code> 发给后端。<code>backend/main.py</code> 先做安检:只收 <code>.pdf</code> 后缀、单份不超过 50MB、一次最多 10 份、不收空文件;任何一份不合格就整批回滚删除。合格的文件被加上随机前缀存进临时目录,然后交给 <code>orchestrator.create_job()</code>——它生成一个 12 位任务号,<b>起一条后台线程</b>去跑流水线,并立刻把 <code>job_id</code> 返回给浏览器。</p>
<div class="analogy"><b>生活直觉</b>快递下单:柜台不会让你站着等包裹送到目的地,而是先给你一张运单号,包裹在后台流转,你凭单号随时查进度。<code>job_id</code> 就是运单号。</div>
<p>浏览器拿到运单号,马上开一条 <span class="term">SSE</span>(Server-Sent Events,服务器单向推送的长连接):<code>frontend/index.html</code> 第 742 行的 <code>es = new EventSource("/api/jobs/" + jobId + "/events")</code>。从此后端每发生一件事,页面上就多一行「直播字幕」。</p>
<h3>3.2 五站流水线:_run_pipeline</h3>
<p>后台线程跑的是 <code>backend/orchestrator.py</code> 的 <code>_run_pipeline</code>,骨架如下(完整代码在该文件第 120–239 行):</p>
<pre class="code"><span class="kw">def</span> _run_pipeline(job: Job):
st = job.state
<span class="kw">try</span>:
provider = get_provider()
<span class="cm"># ---- 1. 文档解析(MCP: parse_document) ----</span>
job.emit("parse", "running", f"解析 {len(job.files)} 份 PDF 文档…", agent="orchestrator")
<span class="kw">for</span> f <span class="kw">in</span> job.files:
p = pdf_parser.parse_pdf(f["path"]) <span class="cm"># 中略:解析失败跳过、扫描件提示</span>
job.emit("parse", "done", "文档解析完成", payload={"files": st["files"]})
<span class="cm"># ---- 2. 合同理解 Agent(MCP: extract_clauses) ----</span>
job.emit("understand", "running", "合同理解 Agent 正在阅读条款…", agent="contract")
<span class="kw">for</span> p <span class="kw">in</span> parsed:
r = contract_agent.run(p["doc_type"], p["text"]) <span class="cm"># 中略:合并条款/参数/模式</span>
<span class="cm"># ---- 3. 参数绑定 Agent(MCP: bind_parameters) ----</span>
bind_res = binding_agent.run(extracted, scheme <span class="kw">or</span> "none")
<span class="cm"># ---- 4. 计算 Agent(MCP: generate_and_calc) ----</span>
calc = calc_agent.run(scheme <span class="kw">or</span> "none", bind_res["bindings"])
<span class="cm"># ---- 5. 报告生成 ----</span>
st["report_ready"] = True
st["status"] = "done"
job.emit("orchestrator", "done", "全流程执行完毕", payload={"state": {...}})
<span class="kw">except</span> Exception <span class="kw">as</span> e:
st["status"] = "error"
job.emit("orchestrator", "error", f"流程异常:{e}")
<span class="kw">finally</span>:
_cleanup_files(job)</pre>
<p class="code-note">↑ 有删节的骨架(<code>emit</code> 的中间事件、合并逻辑见原文件)。注意三件事:每个阶段都有名字(parse / understand / bind / calc / report);异常被兜住并转成 <code>error</code> 终态事件——流水线永远给前端一个交代;<code>finally</code> 里删除临时 PDF——合同是敏感文件,跑完即焚,不留盘。</p>
<p>逐站看案例 A 发生了什么:</p>
<p><b>① parse(文档解析)。</b>对每份文件调 <code>parse_pdf</code>(第 4 章主角)。产出:合同A → 「合同文档」、报表A → 「业绩报表」,事件里带页数与字数。某份文件解析抛异常只跳过该文件;抽不出文字的标为「疑似扫描件」,提示先 OCR,不参与后续。</p>
<p><b>② understand(合同理解)。</b>逐份调 <code>contract_agent.run</code>:合同产出计提模式、条款摘录、合同侧参数;报表产出指标参数。多份文件的结果合并时有个细节:计提模式以<b>第一份给出非 none 模式的合同</b>为准;有合同但没识别出模式记 <code>"none"</code>;一份合同都没有则记 <code>None</code>(「未上传合同,仅报表指标」)。案例 A 到这站的产出:模式「超额收益法」,若干段条款摘录,十来项自然语言参数。</p>
<p><b>③ bind(参数绑定)。</b>把所有抽取参数一次性交给 <code>binding_agent.run</code>,得到 <code>bindings</code>(每条含标准编码、值、置信度、是否需人工确认)和 <code>missing</code>(该模式下计算必需却没绑到的参数清单)。事件消息会点名:「3 项建议人工确认」「缺失必需参数:日均资产规模(可在下方补录后重算)」。</p>
<p><b>④ calc(计算)。</b><code>calc_agent.run</code> 生成公式、分步求值,事件报出「计算完成:应计提业绩报酬 230.00 万元」;若用到了低置信度参数,金额会挂上「(暂估,含待人工确认参数)」的尾巴。</p>
<p><b>⑤ report(报告)。</b>这一站只做一件小事:置位 <code>report_ready</code>。真正的 HTML 报告是你点「导出报告」时才由 <code>backend/report.py</code> 的 <code>render(job.state)</code> 现场生成的。为什么懒生成?因为你可能在导出前人工确认参数、触发重算——导出时刻的 <code>state</code> 才是最终版本,提前渲染就成了过期快照。</p>
<h3>3.3 事件是怎么变成「直播字幕」的</h3>
<p>贯穿五站的 <code>job.emit(...)</code> 值得单独看。每个任务维护一份全量事件列表和一组订阅者队列:</p>
<pre class="code"><span class="kw">def</span> emit(self, stage, status, message, payload=None, agent=None):
<span class="kw">with</span> self.lock:
ev = {"seq": len(self.events) + 1, "ts": _now(), "stage": stage,
"status": status, "message": message, "agent": agent,
"payload": payload}
self.events.append(ev)
subs = list(self.subscribers)
<span class="kw">for</span> q <span class="kw">in</span> subs:
q.put(ev)
<span class="kw">def</span> subscribe(self):
<span class="cm">"""返回 (历史事件快照, 本连接专属队列)。"""</span>
q = queue.Queue()
<span class="kw">with</span> self.lock:
snapshot = list(self.events)
self.subscribers.append(q)
<span class="kw">return</span> snapshot, q</pre>
<p class="code-note">↑ 来自 backend/orchestrator.py 的 Job 类。<code>emit</code> 在锁内给事件编上自增 <code>seq</code> 并存档,然后广播给所有订阅队列;<code>subscribe</code> 先发历史快照(断线重连也能补看全程),<code>seq</code> 让前端可以幂等去重。</p>
<div class="quiz"><b>想一想</b>如果省掉「每连接一个专属队列」,让两个 SSE 连接同时消费同一个队列,会发生什么?</div>
<p>(答案:<code>queue.get()</code> 是「取走」不是「看一眼」。两个连接会各抢到一半事件,各看半场残缺的直播。所以必须每个连接发一份专属队列,<code>emit</code> 对所有队列广播——同一场直播,人手一台电视。另外 <code>main.py</code> 的事件接口还有两道保险:队列 30 秒没动静就发一条 keepalive 注释保活;收到终态事件(orchestrator done/error)立即断流,不留悬挂连接。)</p>
<h3>3.4 回程票:人工确认重算,与导出</h3>
<p>流水线跑完不代表旅程结束。业务员在页面上修改或确认参数后,前端 <code>POST /api/jobs/{id}/recalc</code>,后端调 <code>orchestrator.recalculate</code>:<b>不重新解析、不重新抽取</b>,只把确认后的 bindings 打上 <code>confirmed</code> 标记、重跑计算 Agent,并 emit 一条新的 calc 事件——所以你会看到页面上的金额「再跳一次」,报告里的参数也会带上「已人工确认」的徽章。最后 <code>GET /api/jobs/{id}/report</code> 下载自包含 HTML 报告,可直接存档或打印。这条「机器初算 → 人工把关 → 机器重算」的回路,就是课题里「支持人工确认」的落点。</p>
<figure class="diagram">
<svg viewBox="0 0 720 408" font-family="Noto Sans SC, sans-serif">
<rect x="16" y="10" width="120" height="38" rx="4" fill="#0F2444"/>
<text x="76" y="33" text-anchor="middle" font-size="12" font-weight="bold" fill="#F3EFE3">浏览器</text>
<rect x="152" y="10" width="130" height="38" rx="4" fill="#2F5FBF"/>
<text x="217" y="26" text-anchor="middle" font-size="12" font-weight="bold" fill="#F3EFE3">FastAPI</text>
<text x="217" y="40" text-anchor="middle" font-size="10.5" fill="#F3EFE3">main.py</text>
<rect x="298" y="10" width="150" height="38" rx="4" fill="#8F6B26"/>
<text x="373" y="26" text-anchor="middle" font-size="12" font-weight="bold" fill="#F3EFE3">编排线程</text>
<text x="373" y="40" text-anchor="middle" font-size="10.5" fill="#F3EFE3">_run_pipeline</text>
<rect x="464" y="10" width="140" height="38" rx="4" fill="#F3EFE3" stroke="#0F2444"/>
<text x="534" y="26" text-anchor="middle" font-size="12" font-weight="bold" fill="#0F2444">MCP 工具</text>
<text x="534" y="40" text-anchor="middle" font-size="10.5" fill="#8F6B26">解析 + 三个 Agent</text>
<rect x="620" y="10" width="88" height="38" rx="4" fill="#F3EFE3" stroke="#8F6B26"/>
<text x="664" y="26" text-anchor="middle" font-size="12" font-weight="bold" fill="#0F2444">报告</text>
<text x="664" y="40" text-anchor="middle" font-size="10.5" fill="#8F6B26">report.py</text>
<line x1="76" y1="52" x2="76" y2="396" stroke="#DBD5C4" stroke-width="1.2" stroke-dasharray="4,4"/>
<line x1="217" y1="52" x2="217" y2="396" stroke="#DBD5C4" stroke-width="1.2" stroke-dasharray="4,4"/>
<line x1="373" y1="52" x2="373" y2="396" stroke="#DBD5C4" stroke-width="1.2" stroke-dasharray="4,4"/>
<line x1="534" y1="52" x2="534" y2="396" stroke="#DBD5C4" stroke-width="1.2" stroke-dasharray="4,4"/>
<line x1="664" y1="52" x2="664" y2="396" stroke="#DBD5C4" stroke-width="1.2" stroke-dasharray="4,4"/>
<line x1="76" y1="72" x2="211" y2="72" stroke="#2F5FBF" stroke-width="1.4"/><polygon points="211,68 217,72 211,76" fill="#2F5FBF"/>
<text x="146" y="66" text-anchor="middle" font-size="11" fill="#2F5FBF">POST /api/jobs(合同A+报表A)</text>
<line x1="217" y1="96" x2="367" y2="96" stroke="#0F2444" stroke-width="1.4"/><polygon points="367,92 373,96 367,100" fill="#0F2444"/>
<text x="295" y="90" text-anchor="middle" font-size="11" fill="#0F2444">create_job():起后台线程</text>
<line x1="217" y1="118" x2="82" y2="118" stroke="#2F5FBF" stroke-width="1.4"/><polygon points="82,114 76,118 82,122" fill="#2F5FBF"/>
<text x="146" y="112" text-anchor="middle" font-size="11" fill="#2F5FBF">立即返回 job_id</text>
<line x1="76" y1="142" x2="211" y2="142" stroke="#2F5FBF" stroke-width="1.4"/><polygon points="211,138 217,142 211,146" fill="#2F5FBF"/>
<text x="146" y="136" text-anchor="middle" font-size="11" fill="#2F5FBF">EventSource 订阅 /events</text>
<line x1="373" y1="170" x2="528" y2="170" stroke="#0F2444" stroke-width="1.4"/><polygon points="528,166 534,170 528,174" fill="#0F2444"/>
<text x="453" y="164" text-anchor="middle" font-size="11" fill="#0F2444">① parse_document ×2</text>
<line x1="373" y1="192" x2="82" y2="192" stroke="#8F6B26" stroke-width="1.4"/><polygon points="82,188 76,192 82,196" fill="#8F6B26"/>
<text x="225" y="186" text-anchor="middle" font-size="11" fill="#8F6B26">SSE:解析完成(类型 / 页数 / 字数)</text>
<line x1="373" y1="218" x2="528" y2="218" stroke="#0F2444" stroke-width="1.4"/><polygon points="528,214 534,218 528,222" fill="#0F2444"/>
<text x="453" y="212" text-anchor="middle" font-size="11" fill="#0F2444">② extract_clauses(逐份)</text>
<line x1="373" y1="240" x2="82" y2="240" stroke="#8F6B26" stroke-width="1.4"/><polygon points="82,236 76,240 82,244" fill="#8F6B26"/>
<text x="225" y="234" text-anchor="middle" font-size="11" fill="#8F6B26">SSE:超额收益法 · 条款+参数</text>
<line x1="373" y1="266" x2="528" y2="266" stroke="#0F2444" stroke-width="1.4"/><polygon points="528,262 534,266 528,270" fill="#0F2444"/>
<text x="453" y="260" text-anchor="middle" font-size="11" fill="#0F2444">③ bind_parameters</text>
<line x1="373" y1="288" x2="82" y2="288" stroke="#8F6B26" stroke-width="1.4"/><polygon points="82,284 76,288 82,292" fill="#8F6B26"/>
<text x="225" y="282" text-anchor="middle" font-size="11" fill="#8F6B26">SSE:绑定完成(含待人工确认项)</text>
<line x1="373" y1="314" x2="528" y2="314" stroke="#0F2444" stroke-width="1.4"/><polygon points="528,310 534,314 528,318" fill="#0F2444"/>
<text x="453" y="308" text-anchor="middle" font-size="11" fill="#0F2444">④ generate_and_calc</text>
<line x1="373" y1="336" x2="82" y2="336" stroke="#8F6B26" stroke-width="1.4"/><polygon points="82,332 76,336 82,340" fill="#8F6B26"/>
<text x="225" y="330" text-anchor="middle" font-size="11" fill="#8F6B26">SSE:230.00 万元 · 全流程 done</text>
<line x1="76" y1="362" x2="211" y2="362" stroke="#2F5FBF" stroke-width="1.4"/><polygon points="211,358 217,362 211,366" fill="#2F5FBF"/>
<text x="146" y="356" text-anchor="middle" font-size="11" fill="#2F5FBF">GET /report 导出</text>
<line x1="217" y1="384" x2="658" y2="384" stroke="#0F2444" stroke-width="1.4"/><polygon points="658,380 664,384 658,388" fill="#0F2444"/>
<text x="440" y="378" text-anchor="middle" font-size="11" fill="#0F2444">render(state) → 自包含 HTML 报告下载</text>
</svg>
<figcaption>图 3-1 · 案例 A 的泳道时序:蓝色为 HTTP 请求,金色为 SSE 推送,深蓝为进程内调用</figcaption>
</figure>
<div class="keypoint"><b>本节要点</b>全部中间数据(files、clauses、extracted_params、bindings、calc)都挂在 <code>job.state</code> 上,事件流只是它的增量投影——「状态是账本,事件是流水」,这是系统既能实时直播、又能断线回放、还能事后重算的关键。</div>
</section>
<section class="chapter" id="ch4">
<h2><span class="chno">第 4 章</span>文档解析:PDF 是怎么被「读懂」的</h2>
<p class="lede">流水线第一站的原料质量决定后面所有站的上限。这一章把 backend/pdf_parser.py 的 88 行代码逐段读完。</p>
<h3>4.1 PDF 不是文本文件</h3>
<p>先纠正一个直觉误区:PDF 不是「一篇存成文件的文章」,而是一套<b>排版指令集</b>——「在坐标 (x, y) 处用某字体画出某个字形」的绘图命令序列。它保证在任何设备上打印出来一模一样,却从没承诺「里面有可复制的文字」。</p>
<div class="analogy"><b>生活直觉</b>PDF 不是一篇文章,而是一叠印刷胶片。多数胶片在图形旁边附了「字符层」(原生 PDF,文字可抽取);但扫描仪产出的胶片只有一张照片(扫描件 PDF),上面的「字」只是墨点,机器看它和看一张风景照没有区别。</div>
<p>本项目用 <span class="term">PyMuPDF</span>(导入名 <code>fitz</code>)抽文本:轻量、快、内存占用低,适合 8GB 设备上的常驻服务。它逐页执行排版指令,把字符按版面顺序拼回文本。</p>
<h3>4.2 parse_pdf:抽文本,并诚实地承认「没读到」</h3>
<pre class="code"><span class="kw">def</span> parse_pdf(path: str) -> dict:
doc = fitz.open(path)
<span class="kw">try</span>:
pages = []
<span class="kw">for</span> i, page <span class="kw">in</span> enumerate(doc):
text = page.get_text("text")
pages.append({"page": i + 1, "text": text})
<span class="kw">finally</span>:
doc.close()
full_text = "\n".join(p["text"] <span class="kw">for</span> p <span class="kw">in</span> pages)
empty = len(full_text.strip()) < 40 <span class="cm"># 图片型/扫描件 PDF:没提取到文字</span>
<span class="kw">return</span> {
"pages": pages,
"text": full_text,
"page_count": len(pages),
"doc_type": classify(full_text),
"empty": empty,
}</pre>
<p class="code-note">↑ backend/pdf_parser.py 第 14–31 行,全文无删节。三个值得停下来的地方,下面逐个说。</p>
<p><b>为什么是 try/finally close。</b><code>fitz.open</code> 打开的是 C 语言层的文档句柄——文件描述符加原生内存,Python 的垃圾回收不保证及时归还。若第 3 页是损坏页、<code>get_text</code> 中途抛异常,没有 <code>finally</code> 的话 <code>close()</code> 永远不会执行。我们的后端是常驻进程,每天解析成百上千份 PDF,泄漏会累积成「too many open files」把服务拖死。<code>finally</code> 的语义是「无论正常走完还是中途炸了,都要还」——像图书馆借书:看没看完、书有没有缺页,还书手续都必须办。</p>
<p><b>empty 标记为什么重要:扫描件陷阱。</b>做个思想实验,删掉 <code>empty</code> 这行会怎样?一份<b>扫描版</b>合同进来,字符层不存在,<code>full_text</code> 是空串。接着 <code>classify("")</code> 两组关键词都得 0 分,按平局规则判成 contract;合同理解 Agent 在空文本里找不到「业绩报酬」,判 <code>scheme_type="none"</code>;系统最后郑重其事地输出:「合同中未识别到业绩报酬条款,无需计提。」——一份<b>明明约定了业绩报酬</b>的合同,被系统盖章「不用提」,几百万收入就此蒸发,而且报告看起来毫无异常。这就是「<b>把没读到当没有</b>」的危险误报。有了 <code>empty</code> 标记,编排 Agent(orchestrator.py 第 143–148 行)会把它拦下:「未提取到文字,疑似扫描件/图片型 PDF,需先 OCR 处理后重新上传,本次不参与条款识别」。</p>
<div class="analogy"><b>生活直觉</b>放射科医生拿到一张没显影的片子,正确的报告是「片子废了,重拍」,而绝不是「未见异常」。「没看到病灶」和「没看到片子」是两回事——系统必须能区分「答案是无」和「无法作答」。</div>
<div class="quiz"><b>想一想</b>阈值为什么定 40 个字符,而不是严格等于 0?</div>
<p>(答案:扫描件常残留少量可抽取字符——页码、水印、页眉日期,严格判 0 会漏掉它们;而一份有效的合同或报表不可能全文不足 40 字。这类阈值不追求理论完美,追求的是把两个分布干净地切开。)</p>
<h3>4.3 classify:二十一个关键词的投票</h3>
<pre class="code"><span class="kw">def</span> classify(text: str) -> str:
<span class="cm">"""合同 vs 报表 关键词打分分类。"""</span>
contract_kw = ["合同", "协议", "甲方", "乙方", "受托人", "投资管理人",
"条款", "签署", "约定", "第一条", "违约"]
report_kw = ["报表", "业绩报告", "收益率", "期初资产", "期末资产",
"单位净值", "份额", "估值", "报告期", "日均资产"]
cs = sum(1 <span class="kw">for</span> k <span class="kw">in</span> contract_kw <span class="kw">if</span> k <span class="kw">in</span> text)
rs = sum(1 <span class="kw">for</span> k <span class="kw">in</span> report_kw <span class="kw">if</span> k <span class="kw">in</span> text)
<span class="cm"># 报表关键词命中显著更多时判为报表</span>
<span class="kw">if</span> rs >= cs + 2:
<span class="kw">return</span> "report"
<span class="kw">if</span> cs >= rs:
<span class="kw">return</span> "contract"
<span class="kw">return</span> "report"</pre>
<p class="code-note">↑ backend/pdf_parser.py 第 34–47 行,全文无删节。合同词表 11 个词,报表词表 10 个词;<code>k in text</code> 只问「出现没有」,不数出现次数——一份长报表把「收益率」写一百遍也只得 1 分,防止篇幅刷票。</p>
<p>值得琢磨的是判决规则的<b>不对称性</b>:平局或合同占优都判合同,报表必须明显占优才判报表。为什么向合同倾斜?看第 1 章那段合同第四条原文:里面有「收益率」,有「日均资产规模」——<b>合同的业绩报酬条款天然会引用报表词汇</b>;反过来,报表里几乎不会出现「甲方」「乙方」「签署」「违约」。词汇的渗透是单向的,所以门槛也要不对称:不能让一份合同因为认真约定了计算口径,反而被自己引用的指标词拽到报表那边去。</p>
<div class="quiz"><b>想一想</b>当 rs = cs + 1(报表恰好多一分)时,代码走到哪个分支?</div>
<p>(答案:第一个条件不满足(差距不足 2),第二个条件 cs >= rs 也不满足,于是落到最后一行 <code>return "report"</code>。也就是说三个分支合起来的实际行为是「报表<b>严格多于</b>合同即判报表」,第一行 <code>rs >= cs + 2</code> 在布尔逻辑上已被兜底分支覆盖。那它是废话吗?不是——它把「报表应当显著占优」的设计意图钉在最显眼的位置,日后若有人把平局规则改严(比如把第二个条件改成 <code>cs > rs</code>),这一行就成了真正的守门员。读代码要学会分辨「逻辑上的行为」和「写给人看的意图」,两者都重要。)</p>
<h3>4.4 find_clause_snippets:按章节撕书,不随手撕半页</h3>
<p>分类之后,还需要从合同全文里<b>定位业绩报酬相关的条款</b>。这个函数是解析层送给下游的第三件礼物:</p>
<pre class="code">_HEAD_RE = re.compile(r"第[一二三四五六七八九十百0-9]+条")
<span class="kw">def</span> find_clause_snippets(text: str, keywords=None, window=420, max_len=620):
<span class="cm">"""定位业绩报酬相关条款(供规则引擎与 LLM 提示词共用)。
优先按「第X条」边界取整条条款,避免把相邻无关条款卷进来;
合同没有条款编号时退回关键词窗口截取。"""</span>
keywords = keywords <span class="kw">or</span> ["业绩报酬", "业绩提成", "超额收益", "高水位"]
hits = []
<span class="kw">for</span> kw <span class="kw">in</span> keywords:
hits += [m.start() <span class="kw">for</span> m <span class="kw">in</span> re.finditer(re.escape(kw), text)]
<span class="kw">if</span> <span class="kw">not</span> hits:
<span class="kw">return</span> []
heads = [m.start() <span class="kw">for</span> m <span class="kw">in</span> _HEAD_RE.finditer(text)]
snippets, seen = [], set()
<span class="kw">if</span> heads:
<span class="kw">for</span> pos <span class="kw">in</span> sorted(hits):
start = max((h <span class="kw">for</span> h <span class="kw">in</span> heads <span class="kw">if</span> h <= pos), default=None)
<span class="kw">if</span> start <span class="kw">is</span> None <span class="kw">or</span> start <span class="kw">in</span> seen:
<span class="kw">continue</span>
seen.add(start)
nxt = min((h <span class="kw">for</span> h <span class="kw">in</span> heads <span class="kw">if</span> h > start), default=len(text))
sn = text[start:nxt].strip()
<span class="kw">if</span> len(sn) > max_len:
sn = sn[:max_len].rstrip() + "……"
snippets.append(sn)
<span class="kw">return</span> snippets
<span class="cm"># 无条款编号的兜底:关键词窗口</span>
spans = []
<span class="kw">for</span> pos <span class="kw">in</span> sorted(hits):
s, e = max(0, pos - 80), min(len(text), pos + window)
<span class="kw">if</span> any(abs(s - a) < 300 <span class="kw">for</span> a, _ <span class="kw">in</span> spans):
<span class="kw">continue</span>
spans.append((s, e))
snippets.append(text[s:e].strip())
<span class="kw">return</span> snippets</pre>
<p class="code-note">↑ backend/pdf_parser.py 第 50–87 行,全文无删节。算法分四步:① <code>hits</code>——四个关键词(业绩报酬/业绩提成/超额收益/高水位)在全文的所有出现位置;② <code>heads</code>——正则找出所有「第X条」的位置,中文数词和阿拉伯数字都认;③ 主路径——对每个命中位置,向左找最近的条款锚点作 <code>start</code>,向右找下一个锚点作 <code>nxt</code>,摘录 <code>[start, nxt)</code> 整条,<code>seen</code> 防止同一条款被重复摘录,超过 620 字截断补「……」;④ 兜底——没有条款编号的合同,退回「命中位置向前 80 字、向后 420 字」开窗,相邻 300 字内的命中合并。</p>
<div class="analogy"><b>生活直觉</b>这是「按章节撕书」而不是「随手撕半页」。你想把书里讲红烧肉的部分寄给朋友:沿章节边界撕,朋友拿到的是完整菜谱;照着「红烧肉」三个字前后各撕半页,纸片上半截是上一章的结尾、下半截缺了火候说明——朋友照做必翻车。</div>
<p>为什么整条摘录如此要紧?看一个真实陷阱:合同 A 第五条写着「乙方按本组合投资管理费的<b>20%</b>提取风险准备金」——同样是「20%」加「提取」,却和业绩报酬毫无关系。合同理解 Agent 的规则引擎(contract_agent.py 第 163–165 行的注释写得很直白)只在这些条款片段<b>内部</b>跑参数正则,正是为了「避免把『风险准备金按管理费20%计提』之类误认为业绩报酬计提比例」。假如这里用关键词窗口截 420 字,从第四条的命中点向后数很容易越进第五条,干扰数字就混进了正则的搜索范围;按「第X条」边界整条摘录,干扰条款被干净地留在窗外。这些片段同时也是给业务员看的「条款摘录」和构造 LLM 提示词的素材(函数注释所言「供规则引擎与 LLM 提示词共用」——当前版本 LLM 通道直接读全文前 12000 字,规则引擎则严格依赖这些片段)。</p>
<div class="quiz"><b>想一想</b><code>seen</code> 为什么按条款起点 <code>start</code> 去重,而不是按关键词去重?</div>
<p>(答案:第四条里「业绩报酬」出现了四五次,「超额收益」也命中——会产生一堆 <code>hits</code>,但它们向左找到的最近锚点都是同一个 <code>start</code>。按条款起点去重,保证「一条条款只摘一次」,与命中了几个关键词、命中了几遍无关。)</p>
<figure class="diagram">
<svg viewBox="0 0 720 292" font-family="Noto Sans SC, sans-serif">
<text x="360" y="22" text-anchor="middle" font-size="13" font-weight="bold" fill="#0F2444">find_clause_snippets:先找锚点,再按条款边界整条摘录</text>
<text x="392" y="52" text-anchor="middle" font-size="12" font-weight="bold" fill="#8F6B26">摘录窗口 = [第四条, 第五条)</text>
<line x1="392" y1="58" x2="392" y2="102" stroke="#8F6B26" stroke-width="1" stroke-dasharray="3,3"/>
<rect x="20" y="110" width="680" height="46" rx="4" fill="#F3EFE3" stroke="#DBD5C4"/>
<line x1="30" y1="122" x2="690" y2="122" stroke="#DBD5C4" stroke-width="1"/>
<line x1="30" y1="134" x2="690" y2="134" stroke="#DBD5C4" stroke-width="1"/>
<line x1="30" y1="146" x2="690" y2="146" stroke="#DBD5C4" stroke-width="1"/>
<line x1="45" y1="102" x2="45" y2="164" stroke="#2F5FBF" stroke-width="1.6"/>
<line x1="135" y1="102" x2="135" y2="164" stroke="#2F5FBF" stroke-width="1.6"/>
<line x1="225" y1="102" x2="225" y2="164" stroke="#2F5FBF" stroke-width="1.6"/>
<line x1="315" y1="102" x2="315" y2="164" stroke="#2F5FBF" stroke-width="1.6"/>
<line x1="470" y1="102" x2="470" y2="164" stroke="#2F5FBF" stroke-width="1.6"/>
<line x1="570" y1="102" x2="570" y2="164" stroke="#2F5FBF" stroke-width="1.6"/>
<line x1="655" y1="102" x2="655" y2="164" stroke="#2F5FBF" stroke-width="1.6"/>
<text x="45" y="96" text-anchor="middle" font-size="11" fill="#2F5FBF">第一条</text>
<text x="135" y="96" text-anchor="middle" font-size="11" fill="#2F5FBF">第二条</text>
<text x="225" y="96" text-anchor="middle" font-size="11" fill="#2F5FBF">第三条</text>
<text x="315" y="96" text-anchor="middle" font-size="11" fill="#2F5FBF">第四条</text>
<text x="470" y="96" text-anchor="middle" font-size="11" fill="#2F5FBF">第五条</text>
<text x="570" y="96" text-anchor="middle" font-size="11" fill="#2F5FBF">第六条</text>
<text x="655" y="96" text-anchor="middle" font-size="11" fill="#2F5FBF">第七条</text>
<rect x="315" y="106" width="155" height="54" fill="#8F6B26" fill-opacity="0.15" stroke="#8F6B26" stroke-width="2"/>
<circle cx="350" cy="140" r="4.5" fill="#8F6B26"/>
<circle cx="395" cy="140" r="4.5" fill="#8F6B26"/>
<circle cx="425" cy="140" r="4.5" fill="#8F6B26"/>
<line x1="350" y1="150" x2="324" y2="150" stroke="#1A2231" stroke-width="1.3"/><polygon points="324,146 318,150 324,154" fill="#1A2231"/>
<line x1="425" y1="150" x2="461" y2="150" stroke="#1A2231" stroke-width="1.3"/><polygon points="461,146 467,150 461,154" fill="#1A2231"/>
<text x="28" y="184" font-size="11.5" fill="#1A2231">① hits:关键词(业绩报酬 / 超额收益 …)的命中位置,即条带上的金色圆点</text>
<text x="28" y="203" font-size="11.5" fill="#1A2231">② start:从命中点向左找最近的「第X条」锚点 → 第四条(seen 去重:多个命中同一条款只摘一次)</text>
<text x="28" y="222" font-size="11.5" fill="#1A2231">③ nxt:向右找下一个锚点 → 第五条;整条摘录 [start, nxt),超过 620 字截断补「……」</text>
<rect x="20" y="240" width="680" height="34" rx="4" fill="#F3EFE3" stroke="#8F6B26"/>
<text x="32" y="261" font-size="11.5" fill="#1A2231">兜底:合同没有「第X条」编号时,退回关键词开窗——向前 80 字、向后 420 字,相邻 300 字内的命中合并为一段</text>
</svg>
<figcaption>图 4-1 · 条款定位算法:文本长带上的「第X条」锚点、关键词命中点与整条摘录窗口</figcaption>
</figure>
<div class="keypoint"><b>本节要点</b>解析层交给下游三件东西:全文 <code>text</code>、类型 <code>doc_type</code>、诚实的 <code>empty</code> 标记,外加条款定位工具。整条流水线最贵的一课就藏在这一站:<b>「没读到」不等于「没有」</b>——能分清这两者的系统,才配去算别人的养老钱。</div>
</section>
<section class="chapter" id="ch5">
<h2><span class="chno">第 5 章</span>合同理解 Agent:把条款读成结构化数据</h2>
<p class="lede">合同是给人读的散文,计算引擎要的是字段整齐的 JSON。本章讲第一位专家——合同理解 Agent——如何把「计提比例为百分之十八」这样的自然语言,变成 <code>{"name_nl":"计提比例","value":18,"unit":"%"}</code>,并且在模型不在场时也不掉链子。</p>
<h3>5.1 双引擎设计:LLM 打头阵,规则兜底</h3>
<div class="analogy"><b>生活直觉</b>把这个 Agent 想成一家翻译社:首选那位见多识广的资深译员(大模型),他什么奇怪措辞都见过;但译员可能请假、可能超时,所以柜台后面永远坐着一位只会照手册办事、但绝不缺勤的实习生(规则引擎)。客户的稿子无论如何都有人接。</div>
<pre class="code">def run(doc_type: str, text: str) -> dict:
<span class="cm">"""MCP 工具 extract_clauses 入口。"""</span>
provider = get_provider()
result = None
if provider.name != "mock":
tpl = _CONTRACT_PROMPT if doc_type == "contract" else _REPORT_PROMPT
prompt = tpl.replace("{{TEXT}}", text[:12000])
result = provider.complete_json(_SYSTEM, prompt)
result = _validate(result)
if result is None:
result = (_rules_contract(text) if doc_type == "contract"
else _rules_report(text))
result["engine"] = "rules"
else:
result["engine"] = "llm"
return result</pre>
<p class="code-note">↑ 来自 backend/agents/contract_agent.py。注意三件事:合同与报表用不同提示词模板;文本截断到前 12000 字(控制 token 成本,业绩报酬条款极少排在合同末尾几十页);LLM 失败或校验不过时 <code>result is None</code>,自动落到规则引擎,并在返回值里用 <code>engine</code> 字段如实标注是谁干的活——前端会把这个标注显示给业务人员。</p>
<h3>5.2 提示词里的每一句话都是防御工事</h3>
<p>系统提示词只有两句话,但每句都有用意:</p>
<pre class="code">_SYSTEM = (
"你是一名企业年金基金合同审阅专家,精通业绩报酬(Performance Fee)条款。"
"你只输出 JSON,不输出任何其他文字。"
)</pre>
<p>「只输出 JSON」不是客套,是<span class="term">结构化输出约束</span>:下游代码要 <code>json.loads</code>,模型多说一句「好的,以下是提取结果:」都会让解析变脆(llm.py 里的 <code>extract_json</code> 还做了括号配对抠取兜底,双保险)。再看用户提示词 <code>_CONTRACT_PROMPT</code> 里的三处细节:</p>
<ul>
<li><b>「百分数只写数字,如 20 表示 20%」</b>——不约定的话,模型有时返回 <code>0.2</code>,有时返回 <code>"20%"</code>。20 和 0.2 差一百倍,而钱的世界里没有「差不多」。统一口径后,公式层统一 <code>/100</code>,只除这一次。</li>
<li><b>每个参数必须带 evidence(不超过 60 字的原文依据)</b>。<span class="analogy" style="display:inline"><b>生活直觉</b></span>——就像要求学生答题必须标注引用页码:老师不必重读全书,翻到那一页就能查证。业务人员在前端看到「计提比例 20%」时,旁边就是原文「对超额部分计提业绩报酬,计提比例为20%」,信与不信一眼定夺。这也是压制幻觉的实招:模型编造一个抄不出原文的参数,成本立刻变高。</li>
<li><b>scheme_type 限定三选一</b>:<code>excess_over_hurdle</code>(超额收益法)、<code>high_water_mark</code>(高水位法)、<code>none</code>。枚举而非自由文本,后面所有分支逻辑才有落脚点。</li>
</ul>
<h3>5.3 {{TEXT}} 占位符:一次真实的爆炸事故</h3>
<p>把合同全文塞进模板,我们用的是最「笨」的写法:</p>
<pre class="code">prompt = tpl.replace("{{TEXT}}", text[:12000])</pre>
<p>为什么不用更「Pythonic」的 <code>tpl % text</code> 或 <code>tpl.format(text=...)</code>?因为我们真炸过。提示词模板里本身就有百分号——「如 20 表示 20%;」——用 % 格式化时,解释器读到「%」就去找格式符,撞上后面的中文全角括号「)」,当场抛出 <code>ValueError: unsupported format character</code>。换 <code>str.format</code> 也一样死:模板里的示例 JSON <code>{"scheme_type": "..."}</code> 满是花括号,会被当成占位符。而 <code>str.replace</code> 没有任何元字符概念,它只认识「{{TEXT}}」这串字面量。</p>
<div class="keypoint"><b>本节要点</b>拼接「模板 + 不可信文本」时,越没有魔法的 API 越安全——<code>replace</code> 不解释任何字符,就没有任何字符能引爆它。</div>
<h3>5.4 规则引擎(上):先把文本「熨平」</h3>
<p>规则引擎的第一步不是匹配,而是<span class="term">归一化</span>:</p>
<pre class="code">_FULLWIDTH = str.maketrans("%0123456789.:;,", "%0123456789.:;,")
def _normalize(text: str) -> str:
<span class="cm">"""去掉 PDF 换行/空白(中文无需空格分词),全角数字/百分号转半角,提升正则命中率。"""</span>
return re.sub(r"\s+", "", text).translate(_FULLWIDTH)</pre>
<p class="code-note">↑ backend/agents/contract_agent.py。PDF 抽取的文字是按排版流出来的,换行会把「业绩基准」拆成「业 绩基准」——正则 <code>业绩基准</code> 就此扑空。中文没有空格分词的负担,所以干脆删光所有空白。第二拳打向全角字符:真实合同里「计提比例为20%」(benchmark 案例 E02 的原文)用的是全角数字与全角百分号,<code>translate</code> 一次性转成半角。</p>
<p>还有一类数字连全角都不是——中文数词。E03 的原文写「计提比例为百分之十八」,于是有了 <code>_cn2num</code>:</p>
<pre class="code">def _cn2num(s: str):
<span class="cm">"""「十八」→18、「二十」→20、「二十五」→25、「五」→5;解析失败返回 None。"""</span>
...
if "十" in s:
left, _, right = s.partition("十")
tens = _CN_DIG.get(left, 1) if left else 1 <span class="cm"># 「十八」= 1×10+8</span>
ones = _CN_DIG.get(right, 0) if right else 0
return tens * 10 + ones</pre>
<p>以「十」为轴劈开:左边是十位(缺省为 1),右边是个位(缺省为 0)。「十八」= 1×10+8,「二十」= 2×10+0,覆盖 0–99——合同里的计提比例不会超过这个范围。</p>
<h3>5.5 规则引擎(下):正则备选链与负例防线</h3>
<p>真实合同同一个意思有十几种写法,所以每个参数不是一条正则,而是一条<span class="term">备选链</span>(用 <code>or</code> 串起来,前面的命中就短路):</p>
<pre class="code"><span class="cm"># 计提比例(多种措辞:为20% / 百分之十八 / 按22.5%的比例 / 的20%计提业绩报酬)</span>
m = (re.search(r"计提比例[为为::]*" + _NUM + r"%", text)
or re.search(_NUM + r"%[的]?(?:比例)?(?:计提|提取)业绩(?:报酬|提成)", text)
or re.search(r"(?:按|以)[其]?" + _NUM + r"%(?:的比例)?(?:计提|提取)", text))</pre>
<p>每一环都对应 benchmark 数据集(testdata/benchmark/gen_benchmark.py)里的真实措辞变体:</p>
<table class="tbl"><thead><tr><th>备选环</th><th>命中的真实案例句</th><th>案例号</th></tr></thead><tbody>
<tr><td>计提比例为X%</td><td>「计提比例为20%」「计提比例为20%」「业绩报酬计提比例 20%」</td><td>E01 / E02 / E09</td></tr>
<tr><td>X%…计提业绩</td><td>「就超额部分按22.5%的比例计提业绩报酬」「对超额收益的20%计提业绩报酬」(倒装)</td><td>E04 / E07</td></tr>
<tr><td>按/以X%计提</td><td>兜住既不带「计提比例」前缀、后面也不紧跟「业绩」的写法</td><td>兜底环</td></tr>
<tr><td>百分之+中文数词</td><td>「计提比例为百分之十八」→ _cn2num → 18</td><td>E03</td></tr>
</tbody></table>
<p>业绩基准同理:「业绩基准为年化收益率4.5%」(E01)、「业绩比较基准收益率为3.85%」(E04)、「门槛收益率为5%」(E05),以及最刁的复合表述 E11——「一年期定期存款基准利率加2.25个百分点,<b>即年化3.75%</b>」,专门有一环 <code>即年化(?:收益率)?X%</code> 去取落地数值而不是点差。上限也有两环:「不得超过…日均资产规模的1%」(E01)与「计提上限为当年日均资产净值的0.9%」(E10)。</p>
<div class="quiz"><b>想一想</b>参数正则为什么只在业绩报酬条款片段(<code>find_clause_snippets</code> 按「第X条」边界圈出)里搜索,而不在整份合同全文里搜?</div>
<p>(答案:E05 案例的第五条写着「乙方按本组合投资管理费的20%提取风险准备金」——全文搜索会把这个 20% 误认成计提比例。先按条款边界圈地、再在圈内匹配,干扰条款根本进不了搜索范围。代码注释原话:「避免把『风险准备金按管理费20%计提』之类误认为业绩报酬计提比例」。)</p>
<p>最后是<b>负例防线</b>。N02 案例是个强干扰负例:全文出现「业绩比较基准为年化收益率4.5%」(但仅用于考核评价),同时白纸黑字写着「甲方不计提业绩报酬,乙方亦不得以任何名义收取业绩报酬或业绩提成」。只靠关键词「业绩报酬」判定有无条款,必然误报。所以规则引擎先做否定检测:</p>
<pre class="code">negation = re.search(r"不(?:得以任何名义)?(?:计提|收取)业绩(?:报酬|提成)|无业绩报酬", full)
positive = re.search(r"计提比例|百分之[零一二两三四五六七八九十]{1,3}(?:计提|的比例)|"
r"[0-9.]+%[的]?(?:比例)?(?:计提|提取)业绩|按高水位法计提|"
r"超(?:额|过)[^。]{0,30}计提业绩", full)
if negation and not positive:
return {"scheme_type": "none", "clauses": [], "params": []}</pre>
<p class="code-note">↑ 判定逻辑是「出现否定句、且全文找不到任何正向计提约定」才判 none——防止某些合同一边说旧组合不计提、一边约定新口径。少了这道防线,N02 会被算出一笔根本不存在的报酬,这在业务上是事故级错误。</p>
<h3>5.6 _validate:给 LLM 的答卷装订线</h3>
<p>LLM 的返回哪怕是合法 JSON,也可能缺字段、多字段、类型跑偏。<code>_validate</code> 做三件事:不是 dict 或 <code>params</code> 不是列表→整体作废(返回 None,触发规则兜底);每个参数必须有 <code>name_nl</code>,否则丢弃;<code>value/unit/evidence</code> 缺失的用 <code>setdefault</code> 补上安全默认值。它不试图「修好」模型的错误答案,只保证进入下游的数据形状永远合法——修复交给规则引擎重做,这比在坏数据上缝缝补补可靠得多。</p>
<figure class="diagram">
<svg viewBox="0 0 720 330" font-family="Noto Sans SC, sans-serif">
<text x="26" y="24" font-size="13" fill="#0F2444" font-weight="bold">合同A / E01 · 第四条 业绩报酬(原文节选)</text>
<rect x="16" y="34" width="330" height="252" rx="4" fill="#F3EFE3" stroke="#DBD5C4"/>
<text x="28" y="60" font-size="12.5" fill="#1A2231">双方约定按<tspan fill="#8F6B26" font-weight="bold">超额收益法</tspan>计提业绩报酬:</text>
<text x="28" y="84" font-size="12.5" fill="#1A2231">(一)业绩基准为年化收益率<tspan fill="#8F6B26" font-weight="bold">4.5%</tspan>;</text>
<text x="28" y="108" font-size="12.5" fill="#1A2231">(二)当年时间加权收益率超过业绩</text>
<text x="28" y="130" font-size="12.5" fill="#1A2231">基准时,对超额部分计提业绩报酬,</text>
<text x="28" y="152" font-size="12.5" fill="#1A2231">计提比例为<tspan fill="#8F6B26" font-weight="bold">20%</tspan>;</text>
<text x="28" y="176" font-size="12.5" fill="#1A2231">(三)业绩报酬<tspan fill="#8F6B26" font-weight="bold">按年度计提</tspan>;</text>
<text x="28" y="200" font-size="12.5" fill="#1A2231">(四)当年计提的业绩报酬总额不得</text>
<text x="28" y="222" font-size="12.5" fill="#1A2231">超过当年日均资产规模的<tspan fill="#8F6B26" font-weight="bold">1%</tspan>。</text>
<text x="28" y="272" font-size="11" fill="#8F6B26">—— testdata/benchmark/E01_合同.pdf 真实条款</text>
<text x="446" y="24" font-size="13" fill="#0F2444" font-weight="bold">extract_clauses 输出(JSON)</text>
<rect x="430" y="34" width="276" height="252" rx="4" fill="#0F2444"/>
<text x="442" y="56" font-size="12" fill="#F3EFE3">{"scheme_type":</text>
<text x="456" y="74" font-size="12" fill="#8F6B26">"excess_over_hurdle"</text><text x="590" y="74" font-size="12" fill="#F3EFE3">,</text>
<text x="442" y="94" font-size="12" fill="#F3EFE3">"params": [</text>
<text x="454" y="114" font-size="12" fill="#F3EFE3">{"name_nl":"业绩基准",</text>
<text x="466" y="132" font-size="12" fill="#F3EFE3">"value": <tspan fill="#8F6B26">4.5</tspan>, "unit":"%",</text>
<text x="466" y="150" font-size="12" fill="#DBD5C4">"evidence":"业绩基准为年化…"},</text>
<text x="454" y="172" font-size="12" fill="#F3EFE3">{"name_nl":"计提比例",</text>
<text x="466" y="190" font-size="12" fill="#F3EFE3">"value": <tspan fill="#8F6B26">20</tspan>, "unit":"%", …},</text>
<text x="454" y="212" font-size="12" fill="#F3EFE3">{"name_nl":"计提上限",</text>
<text x="466" y="230" font-size="12" fill="#F3EFE3">"value": <tspan fill="#8F6B26">1.0</tspan>, "unit":"%", …},</text>
<text x="454" y="252" font-size="12" fill="#F3EFE3">{"name_nl":"计提频率",</text>
<text x="466" y="270" font-size="12" fill="#F3EFE3">"value": <tspan fill="#8F6B26">"annual"</tspan>, …}]}</text>
<path d="M348 56 C 392 56, 392 70, 424 70" fill="none" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="424,66 432,70 424,74" fill="#2F5FBF"/>
<path d="M348 80 C 392 80, 392 128, 424 128" fill="none" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="424,124 432,128 424,132" fill="#2F5FBF"/>
<path d="M348 148 C 392 148, 392 186, 424 186" fill="none" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="424,182 432,186 424,190" fill="#2F5FBF"/>
<path d="M348 218 C 392 218, 392 226, 424 226" fill="none" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="424,222 432,226 424,230" fill="#2F5FBF"/>
<path d="M348 172 C 392 172, 392 266, 424 266" fill="none" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="424,262 432,266 424,270" fill="#2F5FBF"/>
<text x="360" y="312" font-size="12" fill="#1A2231" text-anchor="middle">合同理解 Agent(MCP 工具 extract_clauses):每个数值都带 evidence 原文依据,可回溯查证</text>
</svg>
<figcaption>图 5-1 · 一段真实条款到参数 JSON 的映射:金色数值即被抽取的参数,连线即 evidence 的回指关系</figcaption>
</figure>
</section>
<section class="chapter" id="ch6">
<h2><span class="chno">第 6 章</span>参数绑定 Agent:从自然语言到标准字典</h2>
<p class="lede">上一章抽出来的参数名叫「计提比例」「时间加权收益率」——这些还是合同和报表里的「方言」。本章讲第二位专家如何把方言翻译成全系统唯一的「普通话编码」,以及翻译没把握时怎么老老实实举手请人来看。</p>
<h3>6.1 为什么需要标准参数体系</h3>
<div class="analogy"><b>生活直觉</b>全国身份证号系统 vs 每个村自己起外号。村里叫「二狗子」谁都知道是谁,可到了县医院、银行、火车站,没人认得外号——必须有一个全局唯一的身份证号。合同里的「计提比例」「提取比例」「业绩提成比例」「计提率」是四个村的外号,指的都是同一个人:<code>PERF_FEE_RATE</code>。没有这套编号,计算公式就没法写——你总不能让公式引用「那个大概是比例的东西」。</div>
<p>标准字典住在 backend/param_store.py,这是课题「存量合同参数体系梳理」的直接成果。全表共 13 个标准参数(合同侧 5 个、报表侧 8 个),每条长这样:</p>
<pre class="code">{
"code": "HURDLE_RATE",
"name": "业绩基准收益率(年化)",
"aliases": ["业绩基准", "业绩比较基准", "基准收益率", "门槛收益率",
"约定收益率", "业绩报酬计提基准"],
"type": "percent", "unit": "%", "source": "contract",
"desc": "超额收益法下的门槛(Hurdle)收益率",
},</pre>
<p class="code-note">↑ <code>code</code> 是身份证号;<code>aliases</code> 是梳理存量合同攒下的「外号库」;<code>type/unit</code>(percent/amount/number/enum、% / 万元 / 元 / 万份)决定后面怎么做单位归一化;<code>source</code> 标记它天然属于合同侧还是报表侧。</p>
<p>字典之外还有一张<b>必需参数表</b>——每种计提模式开工前必须到齐的人:</p>
<pre class="code">REQUIRED = {
"excess_over_hurdle": ["PERF_FEE_RATE", "HURDLE_RATE",
"PORTFOLIO_RETURN", "AVG_ASSETS"],
"high_water_mark": ["PERF_FEE_RATE", "NAV_END",
"HIGH_WATER_MARK", "FUND_SHARES"],
"none": [],
}</pre>
<p class="code-note">↑ backend/agents/binding_agent.py。绑定结束后拿它对表点名,缺谁就把谁写进 <code>missing</code> 列表,前端提示「可在下方补录后重算」——而不是硬着头皮算个错的。</p>
<h3>6.2 fuzzy_match:给相似度打分的三级规则</h3>
<pre class="code">for n in names:
if n == natural:
return p, 1.0
if n in natural: <span class="cm"># 别名是抽取名的子串:按覆盖度打分</span>
score = 0.70 + 0.25 * len(n) / len(natural)
elif natural in n: <span class="cm"># 抽取名是别名的子串</span>
score = 0.70 + 0.25 * len(natural) / len(n)
else:
score = difflib.SequenceMatcher(None, natural, n).ratio()</pre>
<p class="code-note">↑ backend/param_store.py 的 fuzzy_match 核心。三级规则:完全相等直接满分返回;子串关系按<b>覆盖度</b>给 0.70~0.95 的浮动分(覆盖得越全分越高);两不沾时退到 difflib 序列相似度。低于 0.55 的最佳分视为无法映射。</p>
<p>为什么子串分要乘覆盖度,而不是给个固定分?这里有一场真实的「抢名字」事故。早期版本子串命中给固定高分,于是抽取名「历史最高单位净值」被 <code>NAV_END</code>(期末单位净值)的短别名「单位净值」抢走了——「单位净值」确实是「历史最高单位净值」的子串,固定分和正主打平,而 NAV_END 在字典里排得更靠前,先到先得。修复分两手:其一,完全相等提前 <code>return p, 1.0</code>,正主(HIGH_WATER_MARK 的别名里就有「历史最高单位净值」)直接满分离场;其二,子串分按覆盖度衰减——「单位净值」4 个字只覆盖 8 个字的一半,得 0.70+0.25×4/8=0.825,抢不过更完整的匹配。看一个现在的真实运行结果:H02 案例报表写「历史最高累计单位净值」(多了「累计」二字,谁的别名都不完全等于它),「单位净值」的覆盖度分 0.80,而「历史最高单位净值」的 difflib 相似度约 0.89——高水位胜出,且 0.89≥0.85 免人工确认。短外号再也偷不走长名字。</p>
<div class="keypoint"><b>本节要点</b>模糊匹配的分数设计要保证「更完整的证据得更高的分」,否则短而泛的别名会系统性地劫持长而特异的名字。</div>
<h3>6.3 「基准」的两副面孔:HURDLE_RATE vs BENCHMARK_RETURN</h3>
<div class="analogy"><b>生活直觉</b>年初签合同时写的「目标营业额一千万」,和年底财报里的「实际营业额」是两个数:一个是约定的门槛,一个是跑出来的结果。合同里的「业绩基准 4.5%」是门槛(HURDLE_RATE),报表里的「业绩基准收益率」是当期实际值(BENCHMARK_RETURN,浮动基准时两者不同)。都叫「基准」,绑错了,超额收益就算错了。</div>
<p>光看名字分不开,就看<b>出身</b>——第 5 章抽取时给每个参数打了 <code>source</code> 标记(contract / report),绑定时据此消歧:</p>
<pre class="code"><span class="cm"># 来源消歧:合同侧“业绩基准”→HURDLE_RATE;报表侧→BENCHMARK_RETURN</span>
if "基准" in name:
code = "HURDLE_RATE" if source == "contract" else "BENCHMARK_RETURN"
p = param_store.get_param(code)
conf = 0.95</pre>
<p class="code-note">↑ backend/agents/binding_agent.py 的 _rules_bind。LLM 路径的提示词里也写进了同一条规则——两条腿走路,规矩只有一套。</p>
<h3>6.4 单位归一化:差一个单位就是差一万倍</h3>
<p>E08 案例的报表故意把日均资产规模写成「500,000,000元」,H05 把份额写成「3.00亿份」。标准参数的口径是万元/万份——不换算,5 亿元会被当成 5 亿万元,报酬直接虚增一万倍。这是我们内部 code review 里标记的高危项,修复就是 <code>_normalize_units</code>:</p>
<pre class="code">_TO_WAN_YUAN = {"万元": 1.0, "元": 1e-4, "亿元": 1e4, "百万元": 1e2, "千万元": 1e3}
_TO_WAN_FEN = {"万份": 1.0, "份": 1e-4, "亿份": 1e4}
...
if unit in table:
b["value"] = round(val * table[unit], 6)
b["unit"] = target
b["evidence"] = (b.get("evidence", "") +
f"(已由{unit}换算为{target})").strip()
else: <span class="cm"># 单位缺失或不认识:数值可疑,强制人工确认</span>
b["force_confirm"] = True</pre>
<p class="code-note">↑ 三个设计点:换算完把「已由元换算为万元」追加进 evidence——审计时换算痕迹可见;单位不认识时<b>不猜</b>,置 <code>force_confirm</code> 交给人;对 percent 型参数还有一道量纲自检——绝对值大于 100 的「百分数」必是量纲错乱,同样强制人工确认。500,000,000元 × 1e-4 = 50000 万元,E08 案例由此回到正轨。</p>
<h3>6.5 置信度、人工确认与去重</h3>
<p>每条绑定最终都要过这道闸门:</p>
<pre class="code">b["needs_confirmation"] = (
b.get("confidence", 0) < CONFIRM_THRESHOLD <span class="cm"># 阈值 0.85</span>
or b.get("value") is None
or b.pop("force_confirm", False))</pre>
<p>这就是课题要求的「支持人工确认」的落点:机器不是把不确定的答案藏起来,而是把每条绑定连同置信度摆到前端,低于 0.85、没有数值、或单位可疑的行高亮出来请业务人员改——改完走重算接口(第 8 章讲)。<span class="term">人机协作</span>不是口号,是一个布尔字段加一个重算按钮。</p>
<div class="quiz"><b>想一想</b>同一个 std_code 出现两条候选绑定时,为什么去重规则是「先比有没有值,再比置信度」,而不是只比置信度?</div>
<p>(答案:合同常常提到「业绩基准」却不在该句给出数值——这条绑定可能置信度很高但 <code>value</code> 是 null。只比置信度,它会挤掉另一条置信度稍低但带着 4.5 的绑定,计算 Agent 就断粮了,missing 还会误报。代码里 <code>_rank = (value is not None, confidence)</code>,元组比较天然实现「有值优先,再比分数」。)</p>
<figure class="diagram">
<svg viewBox="0 0 720 360" font-family="Noto Sans SC, sans-serif">
<text x="24" y="26" font-size="13" fill="#0F2444" font-weight="bold">抽取参数(自然语言)</text>
<text x="470" y="26" font-size="13" fill="#0F2444" font-weight="bold">标准参数编码(param_store)</text>
<rect x="24" y="40" width="244" height="44" rx="4" fill="#F3EFE3" stroke="#DBD5C4"/>
<text x="36" y="58" font-size="12.5" fill="#1A2231">计提比例 = 20 %</text>
<text x="36" y="76" font-size="11" fill="#8F6B26">来源:合同</text>
<rect x="24" y="98" width="244" height="44" rx="4" fill="#F3EFE3" stroke="#DBD5C4"/>
<text x="36" y="116" font-size="12.5" fill="#1A2231">业绩基准 = 4.5 %</text>
<text x="36" y="134" font-size="11" fill="#8F6B26">来源:合同(约定门槛)</text>
<rect x="24" y="156" width="244" height="44" rx="4" fill="#F3EFE3" stroke="#DBD5C4"/>
<text x="36" y="174" font-size="12.5" fill="#1A2231">时间加权收益率 = 6.8 %</text>
<text x="36" y="192" font-size="11" fill="#8F6B26">来源:报表</text>
<rect x="24" y="214" width="244" height="44" rx="4" fill="#F3EFE3" stroke="#DBD5C4"/>
<text x="36" y="232" font-size="12.5" fill="#1A2231">当年日均资产规模 = 50,000 万元</text>
<text x="36" y="250" font-size="11" fill="#8F6B26">来源:报表</text>
<rect x="24" y="272" width="244" height="44" rx="4" fill="#F3EFE3" stroke="#DBD5C4"/>
<text x="36" y="290" font-size="12.5" fill="#1A2231">历史最高累计单位净值 = 1.2500 元</text>
<text x="36" y="308" font-size="11" fill="#8F6B26">来源:报表(H02 案例)</text>
<rect x="470" y="40" width="226" height="36" rx="4" fill="#0F2444"/>
<text x="482" y="63" font-size="12.5" fill="#F3EFE3">PERF_FEE_RATE 计提比例</text>
<rect x="470" y="92" width="226" height="36" rx="4" fill="#0F2444"/>
<text x="482" y="115" font-size="12.5" fill="#F3EFE3">HURDLE_RATE 约定基准(门槛)</text>
<rect x="470" y="144" width="226" height="36" rx="4" fill="#DBD5C4"/>
<text x="482" y="167" font-size="12.5" fill="#1A2231">BENCHMARK_RETURN 实际基准</text>
<rect x="470" y="196" width="226" height="36" rx="4" fill="#0F2444"/>
<text x="482" y="219" font-size="12.5" fill="#F3EFE3">PORTFOLIO_RETURN 当期收益率</text>
<rect x="470" y="248" width="226" height="36" rx="4" fill="#0F2444"/>
<text x="482" y="271" font-size="12.5" fill="#F3EFE3">AVG_ASSETS 日均资产规模</text>
<rect x="470" y="300" width="226" height="36" rx="4" fill="#0F2444"/>
<text x="482" y="323" font-size="12.5" fill="#F3EFE3">HIGH_WATER_MARK 高水位</text>
<line x1="268" y1="62" x2="464" y2="58" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="464,54 472,58 464,62" fill="#2F5FBF"/>
<text x="356" y="50" font-size="11.5" fill="#8F6B26">1.00 别名精确命中</text>
<line x1="268" y1="120" x2="464" y2="110" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="464,106 472,110 464,114" fill="#2F5FBF"/>
<text x="336" y="104" font-size="11.5" fill="#8F6B26">0.95 来源消歧→合同侧</text>
<line x1="268" y1="132" x2="464" y2="162" stroke="#DBD5C4" stroke-width="1.4" stroke-dasharray="5 4"/>
<text x="300" y="152" font-size="11" fill="#1A2231">× 报表侧的“基准”才走这条</text>
<line x1="268" y1="178" x2="464" y2="214" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="464,210 472,214 464,218" fill="#2F5FBF"/>
<text x="356" y="192" font-size="11.5" fill="#8F6B26">1.00</text>
<line x1="268" y1="236" x2="464" y2="266" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="464,262 472,266 464,270" fill="#2F5FBF"/>
<text x="356" y="246" font-size="11.5" fill="#8F6B26">1.00</text>
<line x1="268" y1="294" x2="464" y2="318" stroke="#2F5FBF" stroke-width="1.6"/>
<polygon points="464,314 472,318 464,322" fill="#2F5FBF"/>
<text x="300" y="336" font-size="11.5" fill="#8F6B26">0.89 相似度胜出(短别名「单位净值」仅得覆盖度分 0.80)</text>
</svg>
<figcaption>图 6-1 · 绑定映射图:实线为采纳的绑定(标注置信度),虚线为被来源消歧/覆盖度打分否决的候选</figcaption>
</figure>
</section>
<section class="chapter" id="ch7">
<h2><span class="chno">第 7 章</span>计算 Agent 与安全公式引擎</h2>
<p class="lede">前两章的产出是一张干净的变量表。现在要算钱了——而算钱这件事,我们对 LLM 只有四个字:谢绝参与。本章讲这条纪律如何落成代码:白名单 AST 引擎、标准公式对账、财务口径舍入。</p>
<h3>7.1 设计哲学:翻译可以外包,记账必须自己来</h3>
<div class="analogy"><b>生活直觉</b>一家外贸公司可以请外援翻译合同(理解),甚至请顾问起草报价单(生成),但月底入账的每一分钱,必须由自家会计按制度算——因为翻译错了可以重译,账错了要赔钱、要负责。本项目里 LLM 负责「读合同、配参数、写公式」,而公式的<b>执行</b>是一段确定性的本地代码:同样的输入永远得到同样的输出,可复算、可审计。</div>
<p>这条哲学的第一个推论:绝不能把 LLM 生成的公式字符串直接丢给 Python 的 <code>eval()</code>。<code>eval("__import__('os').system('rm -rf ~')")</code> 是合法表达式——公式来自模型输出,而模型读过的合同全文是不可信输入,提示注入完全可能借模型之口写出恶意「公式」。</p>
<h3>7.2 把公式当语法树:AST 白名单引擎</h3>
<p><span class="term">AST</span>(抽象语法树)是把代码解析成的树形结构:<code>a + b * c</code> 变成一棵「加法节点,左枝是 a,右枝是乘法节点」的树。对付不可信公式,正确姿势不是用正则「看一眼字符串长得像不像坏人」,而是解析成树后逐节点检查。</p>
<div class="analogy"><b>生活直觉</b>机场安检不是看行李箱外观顺不顺眼,而是过机、开箱、逐件检查——每件物品对照违禁品清单。AST 求值器就是这台安检机:每种语法节点都要在白名单里登记过才放行,没登记的一律 <code>FormulaError</code>。</div>
<pre class="code">_ALLOWED_BINOPS = {
ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv,
}
_ALLOWED_UNARY = {ast.USub: op.neg, ast.UAdd: op.pos}
_ALLOWED_FUNCS = {"min": min, "max": max, "abs": abs, "round": round}
_ALLOWED_CMP = {
ast.Gt: op.gt, ast.GtE: op.ge, ast.Lt: op.lt, ast.LtE: op.le,
ast.Eq: op.eq, ast.NotEq: op.ne,
}
_MAX_NODES = 200</pre>
<p class="code-note">↑ backend/formula.py 的全部「违禁品对照表」。放行的节点只有七类:数字常量(连布尔值都拒收)、已绑定的变量名(<code>ast.Name</code> 必须在变量表里,否则「未绑定的变量」报错——想引用 <code>__import__</code>?它不在变量表里)、四则运算、正负号、min/max/abs/round 四个函数(且不许关键字参数)、比较、条件表达式。函数调用节点还要求 <code>node.func</code> 必须是白名单里的裸名字——<code>().__class__</code> 这类属性访问节点(<code>ast.Attribute</code>)压根不在放行清单上,走到就抛错。</p>
<p>三道限流阀也值得记住:公式长度超过 2000 字符直接拒收;<code>ast.walk</code> 数节点,超过 200 个判「公式过于复杂」;求值结果若是 NaN 或无穷大也算失败。所有运行时异常(除零、溢出、递归)统一转成 <code>FormulaError</code>——上层只需接住一种异常,就能稳妥回退。</p>
<h3>7.3 被删掉的幂运算:一行公式差点烧掉一台 Mac</h3>
<p>早期白名单里有 <code>ast.Pow</code>(幂)。code review 时被揪了出来:<code>9**9**9</code> 是个完全合法的算术表达式,但 Python 会老老实实去算这个约 3.7 亿位的大整数——在开发用的 8GB 内存 Mac 上,这一行就能把机器拖到失去响应。这叫<span class="term">算法复杂度攻击</span>:不用任何「恶意函数」,纯算术就能耗尽资源。formula.py 的模块注释留下了这条战斗记录:</p>
<pre class="code"><span class="cm">"""……仅允许:加减乘除、比较、条件表达式、min/max/abs/round、数字与已绑定变量名。
(幂运算/取模已从白名单移除:标准公式不需要,且 9**9**9 会造成大整数 DoS。)"""</span></pre>
<p>决策依据很朴素:两种业绩报酬公式(超额收益法、高水位法)一个幂都用不上。<b>白名单的每一项都要有业务理由,拿不出理由的能力一律不给</b>——这与其说是安全技巧,不如说是最小权限原则的肌肉记忆。</p>
<h3>7.4 计算 Agent:标准公式与 LLM 公式对账</h3>
<p>backend/agents/calc_agent.py 里先有一份<b>标准公式</b>(确定性基线):</p>
<pre class="code">if scheme_type == "excess_over_hurdle":
f = "max(PORTFOLIO_RETURN - HURDLE_RATE, 0) / 100 * AVG_ASSETS * PERF_FEE_RATE / 100"